diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 66484562d4..b63488195d 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1329,6 +1329,14 @@ enum SandboxCommands { #[arg(long = "env", value_name = "KEY=VALUE")] envs: Vec, + /// Run in permissive mode: log but do not enforce network policy denials. + /// Connections that would be blocked are allowed through and logged as + /// draft policy proposals. Filesystem (Landlock) restrictions are skipped. + /// Seccomp and process-identity controls remain enforced. + /// Use with --policy to provide protocol hints for richer L7 learning. + #[arg(long)] + permissive: bool, + /// Approval mode for agent-authored policy proposals. /// /// `manual` (default): every proposal lands in the draft inbox for @@ -2622,6 +2630,7 @@ async fn main() -> Result<()> { no_auto_providers, labels, envs, + permissive, approval_mode, command, } => { @@ -2709,6 +2718,7 @@ async fn main() -> Result<()> { labels: labels_map, environment: env_map, approval_mode: &approval_mode, + permissive, }, &tls, )) diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 0182e1b668..d3d678f110 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -1789,6 +1789,7 @@ pub struct SandboxCreateConfig<'a> { pub labels: HashMap, pub environment: HashMap, pub approval_mode: &'a str, + pub permissive: bool, } impl Default for SandboxCreateConfig<'_> { @@ -1812,6 +1813,7 @@ impl Default for SandboxCreateConfig<'_> { labels: HashMap::new(), environment: HashMap::new(), approval_mode: "manual", + permissive: false, } } } @@ -1842,6 +1844,7 @@ pub async fn sandbox_create( labels, environment, approval_mode, + permissive, } = config; if editor.is_some() && !command.is_empty() { @@ -1899,6 +1902,27 @@ pub async fn sandbox_create( .await?; let policy = load_sandbox_policy(policy)?; + + if permissive { + let has_protocol_endpoints = policy.as_ref().is_some_and(|p| { + p.network_policies + .values() + .any(|rule| rule.endpoints.iter().any(|ep| !ep.protocol.is_empty())) + }); + if !has_protocol_endpoints { + eprintln!( + "\n{} Permissive mode: no HTTP endpoints declared in policy.\n \ + L7 audit will be limited to host:port only.\n \ + To capture HTTP method/path detail, add endpoint protocol hints:\n\n \ + endpoints:\n \ + - host: api.example.com\n \ + port: 443\n \ + protocol: rest\n", + "⚠".yellow(), + ); + } + } + let resource_limits = build_sandbox_resource_limits(cpu, memory)?; let driver_config = driver_config_json .map(parse_driver_config_json) @@ -1924,6 +1948,7 @@ pub async fn sandbox_create( policy, providers: configured_providers, template, + permissive, ..SandboxSpec::default() }), name: name.unwrap_or_default().to_string(), diff --git a/crates/openshell-core/src/grpc_client.rs b/crates/openshell-core/src/grpc_client.rs index 92eab00408..e16fd06a2a 100644 --- a/crates/openshell-core/src/grpc_client.rs +++ b/crates/openshell-core/src/grpc_client.rs @@ -749,6 +749,8 @@ pub struct SettingsPollResult { /// When `policy_source` is `Global`, the version of the global policy revision. pub global_policy_version: u32, pub provider_env_revision: u64, + /// When true, the sandbox logs but does not enforce network policy denials. + pub permissive: bool, } fn settings_poll_result(inner: crate::proto::GetSandboxConfigResponse) -> SettingsPollResult { @@ -762,6 +764,7 @@ fn settings_poll_result(inner: crate::proto::GetSandboxConfigResponse) -> Settin settings: inner.settings, global_policy_version: inner.global_policy_version, provider_env_revision: inner.provider_env_revision, + permissive: inner.permissive, } } diff --git a/crates/openshell-core/src/policy.rs b/crates/openshell-core/src/policy.rs index 1645b9da44..e2fc243dea 100644 --- a/crates/openshell-core/src/policy.rs +++ b/crates/openshell-core/src/policy.rs @@ -69,6 +69,8 @@ pub enum NetworkMode { pub struct ProxyPolicy { /// TCP address for a local HTTP proxy (loopback-only). pub http_addr: Option, + /// When true, log but do not enforce policy denials (audit2allow mode). + pub permissive: bool, } #[derive(Debug, Clone, Default)] @@ -104,7 +106,10 @@ impl TryFrom for SandboxPolicy { // can be evaluated by OPA and `inference.local` is always addressable. let network = NetworkPolicy { mode: NetworkMode::Proxy, - proxy: Some(ProxyPolicy { http_addr: None }), + proxy: Some(ProxyPolicy { + http_addr: None, + permissive: false, + }), }; Ok(Self { diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 7a1085f1bf..8a41f34fa8 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -960,7 +960,10 @@ fn process_policy_for_topology( let proxy = process_policy .network .proxy - .get_or_insert(ProxyPolicy { http_addr: None }); + .get_or_insert(ProxyPolicy { + http_addr: None, + permissive: false, + }); if proxy.http_addr.is_none() { proxy.http_addr = Some(SIDECAR_PROCESS_PROXY_ADDR.parse().into_diagnostic()?); } @@ -1656,7 +1659,10 @@ mod baseline_tests { }, network: NetworkPolicy { mode: NetworkMode::Proxy, - proxy: Some(ProxyPolicy { http_addr: None }), + proxy: Some(ProxyPolicy { + http_addr: None, + permissive: false, + }), }, landlock: LandlockPolicy::default(), process: ProcessPolicy::default(), @@ -1811,7 +1817,10 @@ async fn load_policy( filesystem: config.filesystem, network: NetworkPolicy { mode: NetworkMode::Proxy, - proxy: Some(ProxyPolicy { http_addr: None }), + proxy: Some(ProxyPolicy { + http_addr: None, + permissive: false, + }), }, landlock: config.landlock, process: config.process, @@ -1943,7 +1952,7 @@ async fn load_policy( } }; - let policy = match SandboxPolicy::try_from(proto_policy.clone()) { + let mut policy = match SandboxPolicy::try_from(proto_policy.clone()) { Ok(policy) => policy, Err(e) => { report_initial_policy_failure(endpoint, id, loaded_policy_revision.as_ref(), &e) @@ -1951,6 +1960,14 @@ async fn load_policy( return Err(e); } }; + if snapshot.permissive { + if let Some(ref mut proxy) = policy.network.proxy { + proxy.permissive = true; + } + info!( + "Sandbox running in permissive mode: policy denials will be logged but not enforced" + ); + } return Ok(( policy, opa_engine, @@ -2761,7 +2778,10 @@ mod tests { filesystem: FilesystemPolicy::default(), network: NetworkPolicy { mode: NetworkMode::Proxy, - proxy: Some(ProxyPolicy { http_addr }), + proxy: Some(ProxyPolicy { + http_addr, + permissive: false, + }), }, landlock: LandlockPolicy::default(), process: ProcessPolicy::default(), @@ -3008,6 +3028,7 @@ filesystem_policy: settings: std::collections::HashMap::new(), global_policy_version: 0, provider_env_revision: 0, + permissive: false, } } diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index c016531eb6..261177e5f3 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -1251,6 +1251,8 @@ pub(super) async fn handle_get_sandbox_config( let provider_env_revision = compute_provider_env_revision(state.store.as_ref(), &sandbox_provider_names).await?; + let permissive = sandbox.spec.as_ref().is_some_and(|s| s.permissive); + Ok(Response::new(GetSandboxConfigResponse { policy, version, @@ -1260,6 +1262,7 @@ pub(super) async fn handle_get_sandbox_config( policy_source: policy_source.into(), global_policy_version, provider_env_revision, + permissive, })) } diff --git a/crates/openshell-supervisor-network/src/l7/graphql.rs b/crates/openshell-supervisor-network/src/l7/graphql.rs index 12979f0b19..0e3131d228 100644 --- a/crates/openshell-supervisor-network/src/l7/graphql.rs +++ b/crates/openshell-supervisor-network/src/l7/graphql.rs @@ -804,6 +804,8 @@ network_policies: activity_tx: None, dynamic_credentials: None, token_grant_resolver: None, + permissive: false, + denial_tx: None, }; let request_info = crate::l7::L7RequestInfo { action: req.action, diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index 3efdc80057..05e4f7d997 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -20,6 +20,7 @@ use openshell_ocsf::{ }; use std::sync::Arc; use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; +use tokio::sync::mpsc; use tracing::{debug, warn}; /// Context for L7 request policy evaluation. @@ -51,6 +52,10 @@ pub struct L7EvalContext { /// Dynamic token grant resolver for endpoint-bound credentials. pub(crate) token_grant_resolver: Option>, + /// When true, L7 denials are logged but allowed through (permissive mode). + pub permissive: bool, + /// Denial event channel for feeding the proposal pipeline. + pub(crate) denial_tx: Option>, } #[derive(Default)] @@ -270,6 +275,32 @@ where }; let Some(config) = select_l7_config_for_path(configs, &req.target) else { + if ctx.permissive { + if let Some(ref tx) = ctx.denial_tx { + let _ = tx.send(openshell_core::denial::DenialEvent { + host: ctx.host.clone(), + port: ctx.port, + binary: ctx.binary_path.clone(), + ancestors: ctx.ancestors.clone(), + deny_reason: "no L7 endpoint path matched request".to_string(), + denial_stage: "l7".to_string(), + l7_method: Some(req.action.clone()), + l7_path: Some(req.target.clone()), + }); + } + crate::l7::rest::relay_http_request_with_options_guarded( + &req, + client, + upstream, + crate::l7::rest::RelayRequestOptions { + resolver: ctx.secret_resolver.as_deref(), + generation_guard: Some(engine.generation_guard()), + ..Default::default() + }, + ) + .await?; + continue; + } crate::l7::rest::RestProvider::default() .deny( &req, @@ -449,7 +480,27 @@ where let _ = &eval_target; - if allowed || (config.enforcement == EnforcementMode::Audit && !force_deny) { + if ctx.permissive + && !allowed + && !force_deny + && let Some(ref tx) = ctx.denial_tx + { + let _ = tx.send(openshell_core::denial::DenialEvent { + host: ctx.host.clone(), + port: ctx.port, + binary: ctx.binary_path.clone(), + ancestors: ctx.ancestors.clone(), + deny_reason: reason.clone(), + denial_stage: "l7".to_string(), + l7_method: Some(request_info.action.clone()), + l7_path: Some(request_info.target.clone()), + }); + } + + if allowed + || (config.enforcement == EnforcementMode::Audit && !force_deny) + || (ctx.permissive && !force_deny) + { let outcome = crate::l7::rest::relay_http_request_with_options_guarded( &req, client, @@ -906,7 +957,23 @@ where // Store the resolved target for the deny response redaction let _ = &eval_target; - if allowed || config.enforcement == EnforcementMode::Audit { + if ctx.permissive + && !allowed + && let Some(ref tx) = ctx.denial_tx + { + let _ = tx.send(openshell_core::denial::DenialEvent { + host: ctx.host.clone(), + port: ctx.port, + binary: ctx.binary_path.clone(), + ancestors: ctx.ancestors.clone(), + deny_reason: reason.clone(), + denial_stage: "l7".to_string(), + l7_method: Some(request_info.action.clone()), + l7_path: Some(request_info.target.clone()), + }); + } + + if allowed || config.enforcement == EnforcementMode::Audit || ctx.permissive { let req_with_auth = match crate::l7::token_grant_injection::inject_if_needed(req, ctx).await { Ok(req) => req, @@ -1339,7 +1406,27 @@ where let _ = &eval_target; - if allowed || (config.enforcement == EnforcementMode::Audit && !force_deny) { + if ctx.permissive + && !allowed + && !force_deny + && let Some(ref tx) = ctx.denial_tx + { + let _ = tx.send(openshell_core::denial::DenialEvent { + host: ctx.host.clone(), + port: ctx.port, + binary: ctx.binary_path.clone(), + ancestors: ctx.ancestors.clone(), + deny_reason: reason.clone(), + denial_stage: "l7".to_string(), + l7_method: Some(request_info.action.clone()), + l7_path: Some(request_info.target.clone()), + }); + } + + if allowed + || (config.enforcement == EnforcementMode::Audit && !force_deny) + || (ctx.permissive && !force_deny) + { let outcome = crate::l7::rest::relay_http_request_with_resolver_guarded( &req, client, @@ -1926,6 +2013,8 @@ network_policies: activity_tx: None, dynamic_credentials: Some(fixture.dynamic_credentials()), token_grant_resolver: Some(fixture.resolver()), + permissive: false, + denial_tx: None, }; (config, tunnel_engine, ctx, fixture) @@ -1969,6 +2058,8 @@ network_policies: activity_tx: None, dynamic_credentials: Some(fixture.dynamic_credentials()), token_grant_resolver: Some(fixture.resolver()), + permissive: false, + denial_tx: None, }; (generation_guard, ctx, fixture) @@ -2016,6 +2107,8 @@ network_policies: activity_tx: None, dynamic_credentials: None, token_grant_resolver: None, + permissive: false, + denial_tx: None, }; (config, tunnel_engine, ctx) } @@ -2062,6 +2155,8 @@ network_policies: activity_tx: None, dynamic_credentials: None, token_grant_resolver: None, + permissive: false, + denial_tx: None, }; (config, tunnel_engine, ctx) } @@ -2380,6 +2475,8 @@ network_policies: activity_tx: None, dynamic_credentials: None, token_grant_resolver: None, + permissive: false, + denial_tx: None, }; let request = L7RequestInfo { action: "WEBSOCKET_TEXT".into(), @@ -2457,6 +2554,8 @@ network_policies: activity_tx: None, dynamic_credentials: None, token_grant_resolver: None, + permissive: false, + denial_tx: None, }; let mut request = L7RequestInfo { action: "POST".into(), @@ -2580,6 +2679,8 @@ network_policies: activity_tx: None, dynamic_credentials: None, token_grant_resolver: None, + permissive: false, + denial_tx: None, }; let mut request = L7RequestInfo { action: "POST".into(), @@ -2645,6 +2746,8 @@ network_policies: activity_tx: None, dynamic_credentials: None, token_grant_resolver: None, + permissive: false, + denial_tx: None, }; let mut request = L7RequestInfo { action: "POST".into(), @@ -2811,6 +2914,8 @@ network_policies: activity_tx: None, dynamic_credentials: None, token_grant_resolver: None, + permissive: false, + denial_tx: None, }; let (mut app, mut relay_client) = tokio::io::duplex(8192); @@ -2923,6 +3028,8 @@ network_policies: activity_tx: None, dynamic_credentials: None, token_grant_resolver: None, + permissive: false, + denial_tx: None, }; let (mut app, mut relay_client) = tokio::io::duplex(8192); @@ -3048,6 +3155,8 @@ network_policies: activity_tx: None, dynamic_credentials: None, token_grant_resolver: None, + permissive: false, + denial_tx: None, }; let (mut app, mut relay_client) = tokio::io::duplex(8192); @@ -3221,6 +3330,8 @@ network_policies: activity_tx: None, dynamic_credentials: None, token_grant_resolver: None, + permissive: false, + denial_tx: None, }; let (mut app, mut relay_client) = tokio::io::duplex(8192); @@ -3311,6 +3422,8 @@ network_policies: activity_tx: None, dynamic_credentials: None, token_grant_resolver: None, + permissive: false, + denial_tx: None, }; let (mut app, mut relay_client) = tokio::io::duplex(8192); diff --git a/crates/openshell-supervisor-network/src/l7/token_grant_injection.rs b/crates/openshell-supervisor-network/src/l7/token_grant_injection.rs index 0d7c18e996..8259a47808 100644 --- a/crates/openshell-supervisor-network/src/l7/token_grant_injection.rs +++ b/crates/openshell-supervisor-network/src/l7/token_grant_injection.rs @@ -735,6 +735,8 @@ mod tests { activity_tx: None, dynamic_credentials: Some(fixture.dynamic_credentials()), token_grant_resolver: Some(fixture.resolver()), + permissive: false, + denial_tx: None, }; let req = L7Request { action: "GET".to_string(), @@ -772,6 +774,8 @@ mod tests { activity_tx: None, dynamic_credentials: Some(fixture.dynamic_credentials()), token_grant_resolver: Some(fixture.resolver()), + permissive: false, + denial_tx: None, }; let req = L7Request { action: "GET".to_string(), diff --git a/crates/openshell-supervisor-network/src/l7/websocket.rs b/crates/openshell-supervisor-network/src/l7/websocket.rs index e1f92e6ece..0559dccac1 100644 --- a/crates/openshell-supervisor-network/src/l7/websocket.rs +++ b/crates/openshell-supervisor-network/src/l7/websocket.rs @@ -563,7 +563,22 @@ fn inspect_websocket_text_message( None, ); if !allowed && inspector.enforcement == EnforcementMode::Enforce { - return Err(miette!("websocket text message denied by policy")); + if inspector.ctx.permissive { + if let Some(ref tx) = inspector.ctx.denial_tx { + let _ = tx.send(openshell_core::denial::DenialEvent { + host: host.to_string(), + port, + binary: inspector.ctx.binary_path.clone(), + ancestors: inspector.ctx.ancestors.clone(), + deny_reason: reason, + denial_stage: "l7".to_string(), + l7_method: Some(request_info.action), + l7_path: Some(request_info.target), + }); + } + } else { + return Err(miette!("websocket text message denied by policy")); + } } Ok(()) } @@ -633,7 +648,22 @@ fn inspect_graphql_websocket_message( Some(&graphql), ); if (!allowed && inspector.enforcement == EnforcementMode::Enforce) || force_deny { - return Err(miette!("websocket GraphQL message denied by policy")); + if inspector.ctx.permissive && !force_deny { + if let Some(ref tx) = inspector.ctx.denial_tx { + let _ = tx.send(openshell_core::denial::DenialEvent { + host: host.to_string(), + port, + binary: inspector.ctx.binary_path.clone(), + ancestors: inspector.ctx.ancestors.clone(), + deny_reason: reason, + denial_stage: "l7".to_string(), + l7_method: Some(request_info.action), + l7_path: Some(request_info.target), + }); + } + } else { + return Err(miette!("websocket GraphQL message denied by policy")); + } } Ok(()) } @@ -1276,6 +1306,8 @@ network_policies: activity_tx: None, dynamic_credentials: None, token_grant_resolver: None, + permissive: false, + denial_tx: None, }; let (mut client_write, mut relay_read) = tokio::io::duplex(MAX_TEXT_MESSAGE_BYTES + 1024); let (mut relay_write, mut upstream_read) = tokio::io::duplex(MAX_TEXT_MESSAGE_BYTES + 1024); diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index db6315dd86..46c988c32b 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -240,6 +240,7 @@ impl ProxyHandle { ); } + let permissive = policy.permissive; let join = tokio::spawn(async move { // Wait for the OPA engine's symlink resolution reload to complete // before accepting connections. This prevents requests from @@ -300,6 +301,7 @@ impl ProxyHandle { dynamic_credentials, dtx, atx, + permissive, ) .await { @@ -558,6 +560,7 @@ async fn handle_tcp_connection( >, denial_tx: Option>, activity_tx: Option, + permissive: bool, ) -> Result<()> { let mut buf = vec![0u8; MAX_HEADER_BYTES]; let mut used = 0usize; @@ -606,6 +609,7 @@ async fn handle_tcp_connection( dynamic_credentials, denial_tx.as_ref(), activity_tx.as_ref(), + permissive, ) .await; } @@ -703,44 +707,75 @@ async fn handle_tcp_connection( // Allowed connections are logged after the L7 config check (below) // so we can distinguish CONNECT (L4-only) from CONNECT_L7 (L7 follows). if matches!(decision.action, NetworkAction::Deny { .. }) { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Open) - .action(ActionId::Denied) - .disposition(DispositionId::Blocked) - .severity(SeverityId::Medium) - .status(StatusId::Failure) - .dst_endpoint(Endpoint::from_domain(&host_lc, port)) - .src_endpoint_addr(peer_addr.ip(), peer_addr.port()) - .actor_process( - Process::from_bypass(&binary_str, &pid_str, &ancestors_str) - .with_cmd_line(&cmdline_str), + if permissive { + let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Open) + .action(ActionId::Allowed) + .disposition(DispositionId::Logged) + .severity(SeverityId::Medium) + .status(StatusId::Success) + .dst_endpoint(Endpoint::from_domain(&host_lc, port)) + .src_endpoint_addr(peer_addr.ip(), peer_addr.port()) + .actor_process( + Process::from_bypass(&binary_str, &pid_str, &ancestors_str) + .with_cmd_line(&cmdline_str), + ) + .firewall_rule("-", "opa") + .message(format!("CONNECT audit-allow {host_lc}:{port} (permissive)")) + .status_detail(&deny_reason) + .build(); + ocsf_emit!(event); + emit_denial( + &denial_tx, + &host_lc, + port, + &binary_str, + &decision, + &deny_reason, + "connect", + ); + emit_activity(&activity_tx, true, "connect_policy_permissive"); + // Fall through to allow path — SSRF checks still apply below + } else { + let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Open) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(SeverityId::Medium) + .status(StatusId::Failure) + .dst_endpoint(Endpoint::from_domain(&host_lc, port)) + .src_endpoint_addr(peer_addr.ip(), peer_addr.port()) + .actor_process( + Process::from_bypass(&binary_str, &pid_str, &ancestors_str) + .with_cmd_line(&cmdline_str), + ) + .firewall_rule("-", "opa") + .message(format!("CONNECT denied {host_lc}:{port}")) + .status_detail(&deny_reason) + .build(); + ocsf_emit!(event); + emit_denial( + &denial_tx, + &host_lc, + port, + &binary_str, + &decision, + &deny_reason, + "connect", + ); + emit_activity(&activity_tx, true, "connect_policy"); + respond( + &mut client, + &build_json_error_response( + 403, + "Forbidden", + "policy_denied", + &format!("CONNECT {host_lc}:{port} not permitted by policy"), + ), ) - .firewall_rule("-", "opa") - .message(format!("CONNECT denied {host_lc}:{port}")) - .status_detail(&deny_reason) - .build(); - ocsf_emit!(event); - emit_denial( - &denial_tx, - &host_lc, - port, - &binary_str, - &decision, - &deny_reason, - "connect", - ); - emit_activity(&activity_tx, true, "connect_policy"); - respond( - &mut client, - &build_json_error_response( - 403, - "Forbidden", - "policy_denied", - &format!("CONNECT {host_lc}:{port} not permitted by policy"), - ), - ) - .await?; - return Ok(()); + .await?; + return Ok(()); + } } // Resolve the route's TLS treatment up front. `query_tls_mode` reads only @@ -1089,7 +1124,8 @@ async fn handle_tcp_connection( // Check if endpoint has L7 config for protocol-aware inspection, and // retain the generation for HTTP passthrough keep-alive tunnels. - let l7_route = query_l7_route_snapshot(&opa_engine, &decision, &host_lc, port); + let l7_route = + query_l7_route_snapshot_with_permissive(&opa_engine, &decision, &host_lc, port, permissive); let should_inspect_l7 = l7_inspection_active(l7_route.as_ref()); // Log the allowed CONNECT — use CONNECT_L7 when L7 inspection follows, @@ -1148,6 +1184,8 @@ async fn handle_tcp_connection( token_grant_resolver: dynamic_credentials .as_ref() .map(|_| crate::l7::token_grant_injection::default_resolver()), + permissive, + denial_tx: denial_tx.clone(), }; if effective_tls_skip { @@ -2289,16 +2327,26 @@ fn emit_l7_tunnel_close_after_policy_change(host: &str, port: u16, error: miette /// /// Returns `Some(L7EndpointConfig)` if the matched endpoint has L7 config (protocol field), /// `None` for L4-only endpoints. +#[cfg(test)] fn query_l7_route_snapshot( engine: &OpaEngine, decision: &ConnectDecision, host: &str, port: u16, ) -> Option { - // Only query if action is Allow (not Deny) + query_l7_route_snapshot_with_permissive(engine, decision, host, port, false) +} + +fn query_l7_route_snapshot_with_permissive( + engine: &OpaEngine, + decision: &ConnectDecision, + host: &str, + port: u16, + permissive: bool, +) -> Option { let has_policy = match &decision.action { NetworkAction::Allow { matched_policy } => matched_policy.is_some(), - NetworkAction::Deny { .. } => false, + NetworkAction::Deny { .. } => permissive, }; if !has_policy { return None; @@ -3332,6 +3380,7 @@ async fn handle_forward_proxy( >, denial_tx: Option<&mpsc::UnboundedSender>, activity_tx: Option<&ActivitySender>, + permissive: bool, ) -> Result<()> { // 1. Parse the absolute-form URI. `path` is marked `mut` so that, when an // L7 config applies, the canonicalized form produced below replaces it @@ -3459,11 +3508,45 @@ async fn handle_forward_proxy( .join(", ") }; - // 4. Only proceed on explicit Allow — reject Deny + // 4. Only proceed on explicit Allow — reject Deny (unless permissive) let matched_policy = match &decision.action { NetworkAction::Allow { matched_policy } => matched_policy.clone(), NetworkAction::Deny { reason } => { - { + if permissive { + let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Other) + .action(ActionId::Allowed) + .disposition(DispositionId::Logged) + .severity(SeverityId::Medium) + .status(StatusId::Success) + .http_request(HttpRequest::new( + method, + OcsfUrl::new("http", &host_lc, &path, port), + )) + .dst_endpoint(Endpoint::from_domain(&host_lc, port)) + .src_endpoint(Endpoint::from_ip(peer_addr.ip(), peer_addr.port())) + .actor_process( + Process::from_bypass(&binary_str, &pid_str, &ancestors_str) + .with_cmd_line(&cmdline_str), + ) + .firewall_rule("-", "opa") + .message(format!( + "FORWARD audit-allow {method} {host_lc}:{port}{path} (permissive)" + )) + .build(); + ocsf_emit!(event); + emit_denial_simple( + denial_tx, + &host_lc, + port, + &binary_str, + &decision, + reason, + "forward", + ); + emit_activity_simple(activity_tx, true, "forward_policy_permissive"); + None // fall through with no matched policy + } else { let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Other) .action(ActionId::Denied) @@ -3484,28 +3567,28 @@ async fn handle_forward_proxy( .message(format!("FORWARD denied {method} {host_lc}:{port}{path}")) .build(); ocsf_emit!(event); + emit_denial_simple( + denial_tx, + &host_lc, + port, + &binary_str, + &decision, + reason, + "forward", + ); + emit_activity_simple(activity_tx, true, "forward_policy"); + respond( + client, + &build_json_error_response( + 403, + "Forbidden", + "policy_denied", + &format!("{method} {host_lc}:{port}{path} not permitted by policy"), + ), + ) + .await?; + return Ok(()); } - emit_denial_simple( - denial_tx, - &host_lc, - port, - &binary_str, - &decision, - reason, - "forward", - ); - emit_activity_simple(activity_tx, true, "forward_policy"); - respond( - client, - &build_json_error_response( - 403, - "Forbidden", - "policy_denied", - &format!("{method} {host_lc}:{port}{path} not permitted by policy"), - ), - ) - .await?; - return Ok(()); } }; let policy_str = matched_policy.as_deref().unwrap_or("-"); @@ -3582,13 +3665,16 @@ async fn handle_forward_proxy( token_grant_resolver: dynamic_credentials .as_ref() .map(|_| crate::l7::token_grant_injection::default_resolver()), + permissive, + denial_tx: denial_tx.cloned(), }; let mut l7_activity_pending = false; // 4b. If the endpoint has L7 config, evaluate the request against // L7 policy. The forward proxy handles exactly one request per // connection (Connection: close), so a single evaluation suffices. - if let Some(route) = query_l7_route_snapshot(&opa_engine, &decision, &host_lc, port) + if let Some(route) = + query_l7_route_snapshot_with_permissive(&opa_engine, &decision, &host_lc, port, permissive) && !route.configs.is_empty() { if route.generation != forward_generation_guard.captured_generation() { @@ -3706,159 +3792,131 @@ async fn handle_forward_proxy( return Ok(()); } }; - let Some(l7_config) = select_l7_config_for_path(&route.configs, &path) else { - emit_activity_simple(activity_tx, true, "l7_policy"); - respond( - client, - &build_json_error_response( - 403, - "Forbidden", - "policy_denied", - &format!("{method} {host_lc}:{port}{path} did not match an L7 endpoint path"), - ), - ) - .await?; - return Ok(()); - }; - if crate::l7::rest::request_is_h2c_upgrade(&forward_request_bytes) { - let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Other) - .action(ActionId::Denied) - .disposition(DispositionId::Blocked) - .severity(SeverityId::Medium) - .status(StatusId::Failure) - .http_request(HttpRequest::new( - method, - OcsfUrl::new("http", &host_lc, &path, port), - )) - .dst_endpoint(Endpoint::from_domain(&host_lc, port)) - .src_endpoint(Endpoint::from_ip(peer_addr.ip(), peer_addr.port())) - .actor_process( - Process::from_bypass(&binary_str, &pid_str, &ancestors_str) - .with_cmd_line(&cmdline_str), + let l7_config = select_l7_config_for_path(&route.configs, &path); + if l7_config.is_none() { + if permissive { + if let Some(tx) = denial_tx { + let _ = tx.send(DenialEvent { + host: host_lc.clone(), + port, + binary: binary_str.clone(), + ancestors: decision + .ancestors + .iter() + .map(|p| p.display().to_string()) + .collect(), + deny_reason: "no L7 endpoint path matched".to_string(), + denial_stage: "l7".to_string(), + l7_method: Some(method.to_string()), + l7_path: Some(path.clone()), + }); + } + emit_activity_simple(activity_tx, true, "l7_policy_permissive"); + } else { + emit_activity_simple(activity_tx, true, "l7_policy"); + respond( + client, + &build_json_error_response( + 403, + "Forbidden", + "policy_denied", + &format!( + "{method} {host_lc}:{port}{path} did not match an L7 endpoint path" + ), + ), ) - .firewall_rule(policy_str, "l7") - .message(format!( - "FORWARD_L7 denied unsupported h2c upgrade for {method} {host_lc}:{port}{path}" - )) - .status_detail(crate::l7::rest::UNSUPPORTED_H2C_UPGRADE_DETAIL) - .build(); - ocsf_emit!(event); - emit_activity_simple(activity_tx, true, "l7_parse_rejection"); - emit_denial_simple( - denial_tx, - &host_lc, - port, - &binary_str, - &decision, - crate::l7::rest::UNSUPPORTED_H2C_UPGRADE_DETAIL, - "forward-l7-parse-rejection", - ); - respond( - client, - &build_json_error_response( - 403, - "Forbidden", - "unsupported_l7_protocol", - crate::l7::rest::UNSUPPORTED_H2C_UPGRADE_DETAIL, - ), - ) - .await?; - return Ok(()); + .await?; + return Ok(()); + } } - forward_websocket_request = - crate::l7::rest::request_is_websocket_upgrade(&forward_request_bytes); - websocket_extensions = crate::l7::relay::websocket_extension_mode(&l7_config.config); - request_body_credential_rewrite = l7_config.config.protocol == crate::l7::L7Protocol::Rest - && l7_config.config.request_body_credential_rewrite; - forward_upgrade_config = Some(l7_config.config.clone()); - forward_upgrade_target = path.clone(); - forward_upgrade_query_params = query_params.clone(); - let graphql = if l7_config.config.protocol == crate::l7::L7Protocol::Graphql { - let header_end = forward_request_bytes - .windows(4) - .position(|w| w == b"\r\n\r\n") - .map_or(forward_request_bytes.len(), |p| p + 4); - let header_str = std::str::from_utf8(&forward_request_bytes[..header_end]) - .map_err(|_| miette::miette!("Forward GraphQL headers contain invalid UTF-8"))?; - let body_length = crate::l7::rest::parse_body_length(header_str)?; - let mut graphql_request = crate::l7::provider::L7Request { - action: method.to_string(), - target: path.clone(), - query_params: query_params.clone(), - raw_header: forward_request_bytes, - body_length, - }; - let info = match crate::l7::graphql::inspect_graphql_request( - client, - &mut graphql_request, - l7_config.config.graphql_max_body_bytes, - ) - .await - { - Ok(info) => info, - Err(e) => { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Fail) - .severity(SeverityId::Medium) - .status(StatusId::Failure) - .dst_endpoint(Endpoint::from_domain(&host_lc, port)) - .message(format!("FORWARD_GRAPHQL_L7 request rejected: {e}")) - .build(); - ocsf_emit!(event); - emit_activity_simple(activity_tx, true, "l7_parse_rejection"); - respond( - client, - &build_json_error_response( - 400, - "Bad Request", - "invalid_graphql_request", - &format!("GraphQL request rejected before policy evaluation: {e}"), - ), + if let Some(l7_config) = l7_config { + if crate::l7::rest::request_is_h2c_upgrade(&forward_request_bytes) { + let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Other) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(SeverityId::Medium) + .status(StatusId::Failure) + .http_request(HttpRequest::new( + method, + OcsfUrl::new("http", &host_lc, &path, port), + )) + .dst_endpoint(Endpoint::from_domain(&host_lc, port)) + .src_endpoint(Endpoint::from_ip(peer_addr.ip(), peer_addr.port())) + .actor_process( + Process::from_bypass(&binary_str, &pid_str, &ancestors_str) + .with_cmd_line(&cmdline_str), ) - .await?; - return Ok(()); - } - }; - forward_request_bytes = graphql_request.raw_header; - Some(info) - } else { - None - }; - let jsonrpc = if l7_config.config.protocol.is_jsonrpc_family() { - let header_end = forward_request_bytes - .windows(4) - .position(|w| w == b"\r\n\r\n") - .map_or(forward_request_bytes.len(), |p| p + 4); - let header_str = std::str::from_utf8(&forward_request_bytes[..header_end]) - .map_err(|_| miette::miette!("Forward JSON-RPC headers contain invalid UTF-8"))?; - let body_length = crate::l7::rest::parse_body_length(header_str)?; - let mut jsonrpc_request = crate::l7::provider::L7Request { - action: method.to_string(), - target: path.clone(), - query_params: query_params.clone(), - raw_header: forward_request_bytes, - body_length, - }; - if crate::l7::jsonrpc::jsonrpc_receive_stream_request(&jsonrpc_request) { - forward_request_bytes = jsonrpc_request.raw_header; - Some(crate::l7::jsonrpc::JsonRpcRequestInfo::receive_stream()) - } else { - let body = match crate::l7::http::read_body_for_inspection( + .firewall_rule(policy_str, "l7") + .message(format!( + "FORWARD_L7 denied unsupported h2c upgrade for {method} {host_lc}:{port}{path}" + )) + .status_detail(crate::l7::rest::UNSUPPORTED_H2C_UPGRADE_DETAIL) + .build(); + ocsf_emit!(event); + emit_activity_simple(activity_tx, true, "l7_parse_rejection"); + emit_denial_simple( + denial_tx, + &host_lc, + port, + &binary_str, + &decision, + crate::l7::rest::UNSUPPORTED_H2C_UPGRADE_DETAIL, + "forward-l7-parse-rejection", + ); + respond( client, - &mut jsonrpc_request, - l7_config.config.json_rpc_max_body_bytes, + &build_json_error_response( + 403, + "Forbidden", + "unsupported_l7_protocol", + crate::l7::rest::UNSUPPORTED_H2C_UPGRADE_DETAIL, + ), + ) + .await?; + return Ok(()); + } + forward_websocket_request = + crate::l7::rest::request_is_websocket_upgrade(&forward_request_bytes); + websocket_extensions = crate::l7::relay::websocket_extension_mode(&l7_config.config); + request_body_credential_rewrite = l7_config.config.protocol + == crate::l7::L7Protocol::Rest + && l7_config.config.request_body_credential_rewrite; + forward_upgrade_config = Some(l7_config.config.clone()); + forward_upgrade_target = path.clone(); + forward_upgrade_query_params = query_params.clone(); + let graphql = if l7_config.config.protocol == crate::l7::L7Protocol::Graphql { + let header_end = forward_request_bytes + .windows(4) + .position(|w| w == b"\r\n\r\n") + .map_or(forward_request_bytes.len(), |p| p + 4); + let header_str = std::str::from_utf8(&forward_request_bytes[..header_end]) + .map_err(|_| { + miette::miette!("Forward GraphQL headers contain invalid UTF-8") + })?; + let body_length = crate::l7::rest::parse_body_length(header_str)?; + let mut graphql_request = crate::l7::provider::L7Request { + action: method.to_string(), + target: path.clone(), + query_params: query_params.clone(), + raw_header: forward_request_bytes, + body_length, + }; + let info = match crate::l7::graphql::inspect_graphql_request( + client, + &mut graphql_request, + l7_config.config.graphql_max_body_bytes, ) .await { - Ok(body) => body, + Ok(info) => info, Err(e) => { let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Fail) .severity(SeverityId::Medium) .status(StatusId::Failure) .dst_endpoint(Endpoint::from_domain(&host_lc, port)) - .message(format!("FORWARD_JSONRPC_L7 request rejected: {e}")) + .message(format!("FORWARD_GRAPHQL_L7 request rejected: {e}")) .build(); ocsf_emit!(event); emit_activity_simple(activity_tx, true, "l7_parse_rejection"); @@ -3867,152 +3925,230 @@ async fn handle_forward_proxy( &build_json_error_response( 400, "Bad Request", - "invalid_jsonrpc_request", - &format!("JSON-RPC request rejected before policy evaluation: {e}"), + "invalid_graphql_request", + &format!("GraphQL request rejected before policy evaluation: {e}"), ), ) .await?; return Ok(()); } }; - forward_request_bytes = jsonrpc_request.raw_header; - Some(crate::l7::jsonrpc::parse_jsonrpc_body_with_options( - &body, - crate::l7::jsonrpc::JsonRpcInspectionOptions::for_config(&l7_config.config), - )) - } - } else { - None - }; - let request_info = crate::l7::L7RequestInfo { - action: method.to_string(), - target: path.clone(), - query_params, - graphql, - jsonrpc, - }; - - let parse_error_reason = - forward_l7_hard_deny_reason(l7_config.config.protocol, &request_info); - let force_deny = parse_error_reason.is_some(); - let (allowed, reason) = parse_error_reason.map_or_else( - || { - crate::l7::relay::evaluate_l7_request(&tunnel_engine, &l7_ctx, &request_info) - .unwrap_or_else(|e| { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Fail) - .severity(SeverityId::Low) - .status(StatusId::Failure) - .dst_endpoint(Endpoint::from_domain(&host_lc, port)) - .message(format!("L7 eval failed, denying request: {e}")) - .build(); - ocsf_emit!(event); - (false, format!("L7 evaluation error: {e}")) - }) - }, - |reason| (false, reason), - ); - - let decision_str = match (allowed, l7_config.config.enforcement) { - (_, _) if force_deny => "deny", - (true, _) => "allow", - (false, crate::l7::EnforcementMode::Audit) => "audit", - (false, crate::l7::EnforcementMode::Enforce) => "deny", - }; - - { - let (action_id, disposition_id, severity) = match decision_str { - "deny" => (ActionId::Denied, DispositionId::Blocked, SeverityId::Medium), - "allow" | "audit" => ( - ActionId::Allowed, - DispositionId::Allowed, - SeverityId::Informational, - ), - _ => ( - ActionId::Other, - DispositionId::Other, - SeverityId::Informational, - ), + forward_request_bytes = graphql_request.raw_header; + Some(info) + } else { + None }; - let engine_type = match l7_config.config.protocol { - crate::l7::L7Protocol::Graphql => "l7-graphql", - crate::l7::L7Protocol::JsonRpc => "l7-jsonrpc", - crate::l7::L7Protocol::Mcp => "l7-mcp", - _ => "l7", + let jsonrpc = if l7_config.config.protocol.is_jsonrpc_family() { + let header_end = forward_request_bytes + .windows(4) + .position(|w| w == b"\r\n\r\n") + .map_or(forward_request_bytes.len(), |p| p + 4); + let header_str = std::str::from_utf8(&forward_request_bytes[..header_end]) + .map_err(|_| { + miette::miette!("Forward JSON-RPC headers contain invalid UTF-8") + })?; + let body_length = crate::l7::rest::parse_body_length(header_str)?; + let mut jsonrpc_request = crate::l7::provider::L7Request { + action: method.to_string(), + target: path.clone(), + query_params: query_params.clone(), + raw_header: forward_request_bytes, + body_length, + }; + if crate::l7::jsonrpc::jsonrpc_receive_stream_request(&jsonrpc_request) { + forward_request_bytes = jsonrpc_request.raw_header; + Some(crate::l7::jsonrpc::JsonRpcRequestInfo::receive_stream()) + } else { + let body = match crate::l7::http::read_body_for_inspection( + client, + &mut jsonrpc_request, + l7_config.config.json_rpc_max_body_bytes, + ) + .await + { + Ok(body) => body, + Err(e) => { + let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Fail) + .severity(SeverityId::Medium) + .status(StatusId::Failure) + .dst_endpoint(Endpoint::from_domain(&host_lc, port)) + .message(format!("FORWARD_JSONRPC_L7 request rejected: {e}")) + .build(); + ocsf_emit!(event); + emit_activity_simple(activity_tx, true, "l7_parse_rejection"); + respond( + client, + &build_json_error_response( + 400, + "Bad Request", + "invalid_jsonrpc_request", + &format!("JSON-RPC request rejected before policy evaluation: {e}"), + ), + ) + .await?; + return Ok(()); + } + }; + forward_request_bytes = jsonrpc_request.raw_header; + Some(crate::l7::jsonrpc::parse_jsonrpc_body_with_options( + &body, + crate::l7::jsonrpc::JsonRpcInspectionOptions::for_config(&l7_config.config), + )) + } + } else { + None + }; + let request_info = crate::l7::L7RequestInfo { + action: method.to_string(), + target: path.clone(), + query_params, + graphql, + jsonrpc, }; - let log_message = request_info.jsonrpc.as_ref().map_or_else( + + let parse_error_reason = + forward_l7_hard_deny_reason(l7_config.config.protocol, &request_info); + let force_deny = parse_error_reason.is_some(); + let (allowed, reason) = parse_error_reason.map_or_else( || { - let message_prefix = - if l7_config.config.protocol == crate::l7::L7Protocol::Graphql { - "FORWARD_GRAPHQL_L7" - } else { - "FORWARD_L7" - }; - format!( - "{message_prefix} {decision_str} {method} {host_lc}:{port}{path} reason={reason}" - ) + crate::l7::relay::evaluate_l7_request(&tunnel_engine, &l7_ctx, &request_info) + .unwrap_or_else(|e| { + let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Fail) + .severity(SeverityId::Low) + .status(StatusId::Failure) + .dst_endpoint(Endpoint::from_domain(&host_lc, port)) + .message(format!("L7 eval failed, denying request: {e}")) + .build(); + ocsf_emit!(event); + (false, format!("L7 evaluation error: {e}")) + }) }, - |jsonrpc_info| { - let endpoint = format!("{host_lc}:{port}{path}"); - crate::l7::relay::jsonrpc_log_message( - decision_str, + |reason| (false, reason), + ); + + let decision_str = match (allowed, l7_config.config.enforcement) { + (_, _) if force_deny => "deny", + (true, _) => "allow", + (false, crate::l7::EnforcementMode::Audit) => "audit", + (false, crate::l7::EnforcementMode::Enforce) => "deny", + }; + + { + let (action_id, disposition_id, severity) = match decision_str { + "deny" => (ActionId::Denied, DispositionId::Blocked, SeverityId::Medium), + "allow" | "audit" => ( + ActionId::Allowed, + DispositionId::Allowed, + SeverityId::Informational, + ), + _ => ( + ActionId::Other, + DispositionId::Other, + SeverityId::Informational, + ), + }; + let engine_type = match l7_config.config.protocol { + crate::l7::L7Protocol::Graphql => "l7-graphql", + crate::l7::L7Protocol::JsonRpc => "l7-jsonrpc", + crate::l7::L7Protocol::Mcp => "l7-mcp", + _ => "l7", + }; + let log_message = request_info.jsonrpc.as_ref().map_or_else( + || { + let message_prefix = + if l7_config.config.protocol == crate::l7::L7Protocol::Graphql { + "FORWARD_GRAPHQL_L7" + } else { + "FORWARD_L7" + }; + format!( + "{message_prefix} {decision_str} {method} {host_lc}:{port}{path} reason={reason}" + ) + }, + |jsonrpc_info| { + let endpoint = format!("{host_lc}:{port}{path}"); + crate::l7::relay::jsonrpc_log_message( + decision_str, + method, + &endpoint, + jsonrpc_info, + tunnel_engine.captured_generation(), + &reason, + ) + }, + ); + let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Other) + .action(action_id) + .disposition(disposition_id) + .severity(severity) + .http_request(HttpRequest::new( method, - &endpoint, - jsonrpc_info, - tunnel_engine.captured_generation(), - &reason, + OcsfUrl::new("http", &host_lc, &path, port), + )) + .dst_endpoint(Endpoint::from_domain(&host_lc, port)) + .src_endpoint(Endpoint::from_ip(peer_addr.ip(), peer_addr.port())) + .actor_process( + Process::from_bypass(&binary_str, &pid_str, &ancestors_str) + .with_cmd_line(&cmdline_str), ) - }, - ); - let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Other) - .action(action_id) - .disposition(disposition_id) - .severity(severity) - .http_request(HttpRequest::new( - method, - OcsfUrl::new("http", &host_lc, &path, port), - )) - .dst_endpoint(Endpoint::from_domain(&host_lc, port)) - .src_endpoint(Endpoint::from_ip(peer_addr.ip(), peer_addr.port())) - .actor_process( - Process::from_bypass(&binary_str, &pid_str, &ancestors_str) - .with_cmd_line(&cmdline_str), - ) - .firewall_rule(policy_str, engine_type) - .message(log_message) - .build(); - ocsf_emit!(event); - } + .firewall_rule(policy_str, engine_type) + .message(log_message) + .build(); + ocsf_emit!(event); + } - let effectively_denied = force_deny - || (!allowed && l7_config.config.enforcement == crate::l7::EnforcementMode::Enforce); + let effectively_denied = force_deny + || (!allowed + && l7_config.config.enforcement == crate::l7::EnforcementMode::Enforce); - if effectively_denied { - emit_activity_simple(activity_tx, true, "l7_policy"); - emit_denial_simple( - denial_tx, - &host_lc, - port, - &binary_str, - &decision, - &reason, - "forward-l7-deny", - ); - respond( - client, - &build_json_error_response( - 403, - "Forbidden", - "policy_denied", - &format!("{method} {host_lc}:{port}{path} denied by L7 policy: {reason}"), - ), - ) - .await?; - return Ok(()); - } - l7_activity_pending = true; - forward_tunnel_engine = Some(tunnel_engine); + if effectively_denied && !permissive { + emit_activity_simple(activity_tx, true, "l7_policy"); + emit_denial_simple( + denial_tx, + &host_lc, + port, + &binary_str, + &decision, + &reason, + "forward-l7-deny", + ); + respond( + client, + &build_json_error_response( + 403, + "Forbidden", + "policy_denied", + &format!("{method} {host_lc}:{port}{path} denied by L7 policy: {reason}"), + ), + ) + .await?; + return Ok(()); + } + if effectively_denied && permissive { + if let Some(tx) = denial_tx { + let _ = tx.send(DenialEvent { + host: host_lc.clone(), + port, + binary: binary_str.clone(), + ancestors: decision + .ancestors + .iter() + .map(|p| p.display().to_string()) + .collect(), + deny_reason: reason.clone(), + denial_stage: "l7".to_string(), + l7_method: Some(method.to_string()), + l7_path: Some(path.clone()), + }); + } + emit_activity_simple(activity_tx, true, "l7_policy_permissive"); + } + l7_activity_pending = true; + forward_tunnel_engine = Some(tunnel_engine); + } // if let Some(l7_config) } // 5. DNS resolution + SSRF defence (mirrors the CONNECT path logic). @@ -5076,6 +5212,8 @@ network_policies: activity_tx: None, dynamic_credentials: Some(fixture.dynamic_credentials()), token_grant_resolver: Some(fixture.resolver()), + permissive: false, + denial_tx: None, }; (ctx, fixture) @@ -5134,6 +5272,8 @@ network_policies: activity_tx: None, dynamic_credentials: None, token_grant_resolver: None, + permissive: false, + denial_tx: None, }; (config, tunnel_engine, ctx) } @@ -5302,6 +5442,8 @@ network_policies: activity_tx: None, dynamic_credentials: None, token_grant_resolver: None, + permissive: false, + denial_tx: None, }; let query_params = std::collections::HashMap::new(); @@ -5345,6 +5487,8 @@ network_policies: activity_tx: None, dynamic_credentials: None, token_grant_resolver: None, + permissive: false, + denial_tx: None, }; let query_params = std::collections::HashMap::new(); let config = websocket_l7_config(crate::l7::L7Protocol::Rest, false); @@ -8460,6 +8604,7 @@ network_policies: None, // dynamic_credentials Some(denial_tx), // denial_tx — positive allow/deny signal None, // activity_tx + false, // permissive )), ) .await diff --git a/crates/openshell-supervisor-process/src/process.rs b/crates/openshell-supervisor-process/src/process.rs index 72effa98f8..799fffb905 100644 --- a/crates/openshell-supervisor-process/src/process.rs +++ b/crates/openshell-supervisor-process/src/process.rs @@ -613,8 +613,16 @@ impl ProcessHandle { // runs as the sandbox UID, so inaccessible paths are unavailable to // the workload and best-effort compatibility skips them. #[cfg(target_os = "linux")] - let prepared_sandbox = prepare_child_sandbox(policy, workdir, enforcement_mode) + let mut prepared_sandbox = prepare_child_sandbox(policy, workdir, enforcement_mode) .map_err(|err| miette::miette!("Failed to prepare sandbox: {err}"))?; + // In permissive mode, skip filesystem (Landlock) restrictions so the + // workload can run unimpeded while network policy denials are logged. + #[cfg(target_os = "linux")] + if policy.network.proxy.as_ref().is_some_and(|p| p.permissive) + && let Some(prepared) = prepared_sandbox.as_mut() + { + prepared.skip_landlock(); + } #[cfg(target_os = "linux")] let supervisor_identity_mount = if enforcement_mode.uses_privileged_process_setup() { supervisor_identity_mount_from_env().map_err(|err| { diff --git a/crates/openshell-supervisor-process/src/run.rs b/crates/openshell-supervisor-process/src/run.rs index 5a06d97a8f..de516729cf 100644 --- a/crates/openshell-supervisor-process/src/run.rs +++ b/crates/openshell-supervisor-process/src/run.rs @@ -479,6 +479,7 @@ mod tests { mode, proxy: http_addr.map(|http_addr| ProxyPolicy { http_addr: Some(http_addr), + permissive: false, }), }, landlock: LandlockPolicy::default(), diff --git a/crates/openshell-supervisor-process/src/sandbox/linux/mod.rs b/crates/openshell-supervisor-process/src/sandbox/linux/mod.rs index 107a50e370..1a8f6a38d1 100644 --- a/crates/openshell-supervisor-process/src/sandbox/linux/mod.rs +++ b/crates/openshell-supervisor-process/src/sandbox/linux/mod.rs @@ -18,6 +18,14 @@ pub struct PreparedSandbox { policy: SandboxPolicy, } +impl PreparedSandbox { + /// Disable Landlock enforcement while keeping seccomp active. + /// Used by permissive mode to skip filesystem restrictions. + pub fn skip_landlock(&mut self) { + self.landlock = None; + } +} + /// Phase 1: Prepare sandbox restrictions **as root** (before `drop_privileges`). /// /// Opens Landlock `PathFds` while the process still has root privileges, diff --git a/crates/openshell-supervisor-process/src/ssh.rs b/crates/openshell-supervisor-process/src/ssh.rs index f5a3ee0793..a4162ef375 100644 --- a/crates/openshell-supervisor-process/src/ssh.rs +++ b/crates/openshell-supervisor-process/src/ssh.rs @@ -835,9 +835,15 @@ fn spawn_pty_shell( // Phase 1: Prepare Landlock ruleset before the child applies it. #[cfg(target_os = "linux")] - let prepared_sandbox = + let mut prepared_sandbox = crate::process::prepare_child_sandbox(policy, workdir.as_deref(), enforcement_mode) .map_err(|err| anyhow::anyhow!("Failed to prepare sandbox: {err}"))?; + #[cfg(target_os = "linux")] + if policy.network.proxy.as_ref().is_some_and(|p| p.permissive) + && let Some(prepared) = prepared_sandbox.as_mut() + { + prepared.skip_landlock(); + } #[cfg(unix)] { @@ -989,9 +995,15 @@ fn spawn_pipe_exec( // Phase 1: Prepare Landlock ruleset before the child applies it. #[cfg(target_os = "linux")] - let prepared_sandbox = + let mut prepared_sandbox = crate::process::prepare_child_sandbox(policy, workdir.as_deref(), enforcement_mode) .map_err(|err| anyhow::anyhow!("Failed to prepare sandbox: {err}"))?; + #[cfg(target_os = "linux")] + if policy.network.proxy.as_ref().is_some_and(|p| p.permissive) + && let Some(prepared) = prepared_sandbox.as_mut() + { + prepared.skip_landlock(); + } #[cfg(unix)] { diff --git a/docs/sandboxes/policies.mdx b/docs/sandboxes/policies.mdx index 212ba76fce..c3b602f5cb 100644 --- a/docs/sandboxes/policies.mdx +++ b/docs/sandboxes/policies.mdx @@ -92,6 +92,98 @@ openshell sandbox create -- claude The CLI uses the policy from `OPENSHELL_SANDBOX_POLICY` whenever `--policy` is not explicitly provided. +## Discover Policy with Permissive Mode + +When onboarding a new workload, you may not know which endpoints it needs. Permissive mode lets you run the sandbox without enforcing network policy — all connections succeed, but connections that *would have been denied* are logged and turned into draft policy proposals. You review and approve the proposals, then export the learned policy for future use. + +This is similar to SELinux's `audit2allow` workflow: run permissively, observe what the workload needs, then generate a policy from the observations. + +### Quick start + +Create a sandbox in permissive mode and run your workload: + +```shell +openshell sandbox create --permissive --name my-sandbox -- bash +``` + +From inside the sandbox, the workload can reach any public endpoint. Try a few: + +```shell +curl -s https://httpbin.org/get +curl -s https://api.github.com/zen +curl -s https://ifconfig.me +``` + +All three succeed. Without `--permissive`, they would return 403 because no network policy allows them. + +After the workload finishes (or while it runs), check the draft proposals that were generated: + +```shell +openshell rule get my-sandbox --status pending +``` + +```text +Chunk: bd3696f3-... + Status: pending + Rule: allow_httpbin_org_443 + Binary: /usr/bin/curl + Confidence: 65% + Rationale: Allow curl to connect to httpbin.org:443 (HTTPS). + Endpoints: httpbin.org:443 [L4] + +Chunk: 31099f86-... + Status: pending + Rule: allow_api_github_com_443 + Binary: /usr/bin/curl + Confidence: 65% + Rationale: Allow curl to connect to api.github.com:443 (HTTPS). + Endpoints: api.github.com:443 [L4] +``` + +Approve all proposals and export the learned policy: + +```shell +openshell rule approve-all my-sandbox +openshell sandbox get my-sandbox --policy-only > learned-policy.yaml +``` + +Use the exported policy for future sandboxes with full enforcement: + +```shell +openshell sandbox create --policy learned-policy.yaml -- bash +``` + +### What permissive mode changes + +| Layer | Behavior | +|---|---| +| Network policy (L4/L7) | Denied connections are logged as draft proposals but allowed through. | +| Filesystem (Landlock) | Skipped entirely. Filesystem policy must be authored manually. | +| Process (seccomp) | Always enforced. Permissive mode does not weaken process integrity controls. | +| SSRF guards | Always enforced. Loopback, link-local, and cloud metadata addresses remain blocked. | +| Credential injection | Credentials are injected normally for connections that match a policy endpoint. | + +### Combining with a base policy + +Permissive mode works with no policy at all (pure L4 discovery), but providing a base policy with `protocol` hints enables richer L7 learning. Without protocol declarations, the proxy tunnels connections as raw TCP and can only learn host:port — not HTTP method and path. + +```shell +openshell sandbox create --permissive --policy base-policy.yaml -- bash +``` + +If no protocol hints are present, the CLI prints a reminder at startup: + +```text +⚠ Permissive mode: no HTTP endpoints declared in policy. + L7 audit will be limited to host:port only. + To capture HTTP method/path detail, add endpoint protocol hints: + + endpoints: + - host: api.example.com + port: 443 + protocol: rest +``` + ## Iterate on a Running Sandbox To change what the sandbox can access, pull the current policy, edit the YAML, and push the update. The workflow is iterative: create the sandbox, monitor logs for denied actions, pull the policy, modify it, push, and verify. diff --git a/e2e/python/test_sandbox_policy.py b/e2e/python/test_sandbox_policy.py index 5ac37bd27f..28c03d47a6 100644 --- a/e2e/python/test_sandbox_policy.py +++ b/e2e/python/test_sandbox_policy.py @@ -8,7 +8,7 @@ import pytest -from openshell._proto import datamodel_pb2, sandbox_pb2 +from openshell._proto import datamodel_pb2, openshell_pb2, sandbox_pb2 if TYPE_CHECKING: from collections.abc import Callable @@ -2044,3 +2044,369 @@ def test_overlapping_policies_l7_connect_does_not_crash( assert "200" in result.stdout, ( f"Overlapping L7 policies should not crash; expected 200, got: {result.stdout}" ) + + +# ============================================================================= +# Permissive mode tests +# ============================================================================= + + +def test_permissive_mode_allows_denied_connection( + sandbox: Callable[..., Sandbox], +) -> None: + """PERM-1: Permissive mode allows connections that would normally be denied. + + Create a sandbox with a restrictive policy (allow only example.com:443) + and permissive=True. A connection to an unlisted host should succeed + instead of returning 403. + """ + policy = _base_policy( + network_policies={ + "limited": sandbox_pb2.NetworkPolicyRule( + name="limited", + endpoints=[ + sandbox_pb2.NetworkEndpoint(host="example.com", port=443), + ], + binaries=[sandbox_pb2.NetworkBinary(path="/**")], + ), + }, + ) + spec = datamodel_pb2.SandboxSpec(policy=policy, permissive=True) + with sandbox(spec=spec, delete_on_exit=True) as sb: + # Without permissive, this would return 403 (not in policy) + result = sb.exec_python( + _proxy_connect(), args=("api.anthropic.com", 443) + ) + assert result.exit_code == 0, result.stderr + assert "200" in result.stdout, ( + f"Permissive mode should allow denied connections; got: {result.stdout}" + ) + + # Policy-allowed endpoint should also work + result = sb.exec_python(_proxy_connect(), args=("example.com", 443)) + assert result.exit_code == 0, result.stderr + assert "200" in result.stdout + + +def test_permissive_mode_without_policy( + sandbox: Callable[..., Sandbox], +) -> None: + """PERM-2: Permissive mode works with no network policies (pure discovery). + + When no policy rules are defined, all connections are would-be-denied. + In permissive mode they should all succeed. + """ + policy = _base_policy(network_policies={}) + spec = datamodel_pb2.SandboxSpec(policy=policy, permissive=True) + with sandbox(spec=spec, delete_on_exit=True) as sb: + result = sb.exec_python( + _proxy_connect(), args=("api.anthropic.com", 443) + ) + assert result.exit_code == 0, result.stderr + assert "200" in result.stdout, ( + f"Permissive mode with empty policy should allow; got: {result.stdout}" + ) + + +def test_permissive_mode_ssrf_still_blocks( + sandbox: Callable[..., Sandbox], +) -> None: + """PERM-3: SSRF safety guards remain active even in permissive mode. + + Loopback addresses are infrastructure safety guards, not policy controls. + They must stay blocked even when permissive mode is on. + """ + policy = _base_policy(network_policies={}) + spec = datamodel_pb2.SandboxSpec(policy=policy, permissive=True) + with sandbox(spec=spec, delete_on_exit=True) as sb: + result = sb.exec_python(_proxy_connect(), args=("127.0.0.1", 80)) + assert result.exit_code == 0, result.stderr + assert "403" in result.stdout, ( + f"SSRF loopback should remain blocked in permissive mode; got: {result.stdout}" + ) + + +def test_permissive_mode_generates_draft_proposals( + sandbox: Callable[..., Sandbox], + sandbox_client: SandboxClient, +) -> None: + """PERM-4: Permissive mode feeds the draft proposal pipeline. + + Connections that are audit-allowed in permissive mode should generate + pending draft policy proposals via the mechanistic mapper, just like + real denials do in enforce mode. + """ + import time + + policy = _base_policy( + network_policies={ + "limited": sandbox_pb2.NetworkPolicyRule( + name="limited", + endpoints=[ + sandbox_pb2.NetworkEndpoint(host="example.com", port=443), + ], + binaries=[sandbox_pb2.NetworkBinary(path="/**")], + ), + }, + ) + spec = datamodel_pb2.SandboxSpec(policy=policy, permissive=True) + with sandbox(spec=spec, delete_on_exit=True) as sb: + result = sb.exec_python( + _proxy_connect(), args=("api.anthropic.com", 443) + ) + assert result.exit_code == 0, result.stderr + assert "200" in result.stdout, ( + f"Permissive mode should allow; got: {result.stdout}" + ) + + # Wait for the denial aggregator flush interval (~10s default). + time.sleep(15) + + # Query draft proposals via gRPC. + resp = sandbox_client._stub.GetDraftPolicy( + openshell_pb2.GetDraftPolicyRequest( + name=sb.sandbox.name, + status_filter="pending", + ) + ) + + hosts = [ + ep.host + for chunk in resp.chunks + if chunk.proposed_rule + for ep in chunk.proposed_rule.endpoints + ] + assert any("anthropic" in h for h in hosts), ( + f"Expected a pending proposal for api.anthropic.com; got hosts: {hosts}" + ) + + +def test_permissive_mode_forward_proxy( + sandbox: Callable[..., Sandbox], +) -> None: + """PERM-5: Forward-proxy (non-CONNECT) requests are also allowed in permissive mode. + + Plain HTTP requests through the forward proxy use a different code path + than CONNECT tunnels. Permissive mode must bypass both. + """ + policy = _base_policy(network_policies={}) + spec = datamodel_pb2.SandboxSpec(policy=policy, permissive=True) + with sandbox(spec=spec, delete_on_exit=True) as sb: + result = sb.exec_python( + _forward_proxy_raw(), + args=(_PROXY_HOST, _PROXY_PORT, "http://httpbin.org/get"), + ) + assert result.exit_code == 0, result.stderr + assert "403" not in result.stdout, ( + f"Forward proxy should allow in permissive mode; got: {result.stdout}" + ) + + +def test_permissive_mode_l7_deny_allowed( + sandbox: Callable[..., Sandbox], +) -> None: + """PERM-6: L7-enforced denials are overridden by permissive mode. + + A policy with protocol: rest, enforcement: enforce, access: read-only + would deny POST at the L7 layer. In permissive mode the POST should + succeed and generate a denial event. + """ + policy = _base_policy( + network_policies={ + "anthropic": sandbox_pb2.NetworkPolicyRule( + name="anthropic", + endpoints=[ + sandbox_pb2.NetworkEndpoint( + host="api.anthropic.com", + port=443, + protocol="rest", + tls="terminate", + enforcement="enforce", + access="read-only", + ), + ], + binaries=[sandbox_pb2.NetworkBinary(path="/**")], + ), + }, + ) + spec = datamodel_pb2.SandboxSpec(policy=policy, permissive=True) + with sandbox(spec=spec, delete_on_exit=True) as sb: + # POST would be denied at L7 in enforce mode (read-only allows only GET). + # In permissive mode, the POST should go through. + result = sb.exec_python( + _proxy_connect_then_http(), + args=("api.anthropic.com", 443, "POST", "/v1/messages"), + ) + assert result.exit_code == 0, result.stderr + resp = json.loads(result.stdout) + assert "200" in resp["connect_status"], ( + f"CONNECT should succeed; got: {resp['connect_status']}" + ) + assert resp["http_status"] != 403, ( + f"L7 POST should not be proxy-denied in permissive mode; got status {resp['http_status']}" + ) + + +def test_permissive_mode_unlisted_host_generates_proposal( + sandbox: Callable[..., Sandbox], + sandbox_client: SandboxClient, +) -> None: + """PERM-7: L4-denied hosts in permissive mode with a base policy still generate proposals. + + Verifies the full pipeline: permissive sandbox with a base policy, + connection to an unlisted host, denial fed to the aggregator, and proposal + visible as a pending draft chunk. + """ + import time + + policy = _base_policy( + network_policies={ + "anthropic": sandbox_pb2.NetworkPolicyRule( + name="anthropic", + endpoints=[ + sandbox_pb2.NetworkEndpoint( + host="api.anthropic.com", + port=443, + protocol="rest", + tls="terminate", + enforcement="enforce", + access="read-only", + ), + ], + binaries=[sandbox_pb2.NetworkBinary(path="/**")], + ), + }, + ) + spec = datamodel_pb2.SandboxSpec(policy=policy, permissive=True) + with sandbox(spec=spec, delete_on_exit=True) as sb: + # Connect to a host NOT in the policy — L4 denied but permissive-allowed. + result = sb.exec_python( + _proxy_connect(), args=("httpbin.org", 443) + ) + assert result.exit_code == 0, result.stderr + assert "200" in result.stdout, ( + f"Permissive should allow unlisted host; got: {result.stdout}" + ) + + time.sleep(15) + + stub = sandbox_client._stub + draft_resp = stub.GetDraftPolicy( + openshell_pb2.GetDraftPolicyRequest( + name=sb.sandbox.name, + status_filter="pending", + ) + ) + + httpbin_chunks = [ + c + for c in draft_resp.chunks + if c.proposed_rule + and any( + "httpbin" in ep.host for ep in c.proposed_rule.endpoints + ) + ] + assert httpbin_chunks, ( + f"Expected a pending proposal for httpbin.org; " + f"got {len(draft_resp.chunks)} total chunks" + ) + + +def test_permissive_mode_l7_deny_generates_method_path_proposal( + sandbox: Callable[..., Sandbox], + sandbox_client: SandboxClient, +) -> None: + """PERM-8: L7-denied POST in permissive mode generates a proposal with method/path. + + A read-only endpoint denies POST at the L7 layer. In permissive mode the + request goes through, and the denial feeds the aggregator with l7_method + and l7_path. The mechanistic mapper should produce a chunk whose proposed + rule contains L7 rules (method/path samples), not just a bare host:port. + """ + import time + + policy = _base_policy( + network_policies={ + "anthropic": sandbox_pb2.NetworkPolicyRule( + name="anthropic", + endpoints=[ + sandbox_pb2.NetworkEndpoint( + host="api.anthropic.com", + port=443, + protocol="rest", + tls="terminate", + enforcement="enforce", + access="read-only", + ), + ], + binaries=[sandbox_pb2.NetworkBinary(path="/**")], + ), + }, + ) + spec = datamodel_pb2.SandboxSpec(policy=policy, permissive=True) + with sandbox(spec=spec, delete_on_exit=True) as sb: + # POST denied at L7 (read-only), but allowed in permissive mode. + result = sb.exec_python( + _proxy_connect_then_http(), + args=("api.anthropic.com", 443, "POST", "/v1/messages"), + ) + assert result.exit_code == 0, result.stderr + resp = json.loads(result.stdout) + assert "200" in resp["connect_status"], ( + f"CONNECT should succeed; got: {resp['connect_status']}" + ) + assert resp["http_status"] != 403, ( + f"POST should not be proxy-denied in permissive; got {resp['http_status']}" + ) + + time.sleep(20) + + stub = sandbox_client._stub + draft_resp = stub.GetDraftPolicy( + openshell_pb2.GetDraftPolicyRequest( + name=sb.sandbox.name, + status_filter="", + ) + ) + + all_hosts = [ + (ep.host, ep.port, ep.protocol, c.status) + for c in draft_resp.chunks + if c.proposed_rule + for ep in c.proposed_rule.endpoints + ] + anthropic_chunks = [ + c + for c in draft_resp.chunks + if c.proposed_rule + and any( + "anthropic" in ep.host for ep in c.proposed_rule.endpoints + ) + ] + assert anthropic_chunks, ( + f"Expected a proposal from L7 POST denial; " + f"got {len(draft_resp.chunks)} total chunks with endpoints: {all_hosts}" + ) + + # Verify the proposal has L7 rules with the denied method/path. + chunk = anthropic_chunks[0] + l7_endpoints = [ + ep + for ep in chunk.proposed_rule.endpoints + if ep.protocol + ] + assert l7_endpoints, ( + f"Expected L7-aware proposal (protocol set); " + f"got endpoints: " + f"{[(ep.host, ep.port, ep.protocol) for ep in chunk.proposed_rule.endpoints]}" + ) + l7_rules = [ + (rule.allow.method, rule.allow.path) + for ep in l7_endpoints + for rule in ep.rules + if rule.allow.method + ] + assert any( + method == "POST" and "/v1/messages" in path + for method, path in l7_rules + ), f"Expected an L7 rule with method=POST and path /v1/messages; got rules: {l7_rules}" diff --git a/proto/openshell.proto b/proto/openshell.proto index d2d884f2e8..5c85065070 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -332,6 +332,12 @@ message SandboxSpec { // managed fleet-wide. reserved 11; reserved "proposal_approval_mode"; + // When true, the sandbox logs but does not enforce policy denials + // (audit2allow mode). Network policy violations are logged and + // forwarded to the denial aggregator but connections proceed. + // Filesystem (Landlock) restrictions are skipped entirely. + // Seccomp and process-identity controls remain enforced. + bool permissive = 12; } message ResourceRequirements { diff --git a/proto/sandbox.proto b/proto/sandbox.proto index 8a5a593334..2c4b22e7f1 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -329,4 +329,6 @@ message GetSandboxConfigResponse { // Fingerprint for provider credential inputs attached to this sandbox. // Changes when attached provider names or attached provider records change. uint64 provider_env_revision = 8; + // When true, the sandbox is running in permissive (audit2allow) mode. + bool permissive = 9; }