From c4ea03e60ec1c2f819bf1502af491b336f63ef89 Mon Sep 17 00:00:00 2001 From: Russell Bryant Date: Wed, 10 Jun 2026 11:39:28 -0400 Subject: [PATCH] feat(sandbox): add --permissive flag for audit2allow-style policy discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add permissive mode to sandboxes, enabling a single-pass onboarding workflow for discovering what network policy a workload needs. When `openshell sandbox create --permissive` is used, the proxy logs but does not enforce network policy denials at both L4 (TCP connect) and L7 (HTTP method/path) layers. Denied connections succeed with full credential injection, and each denial feeds the existing DenialAggregator → mechanistic mapper → draft proposal pipeline. Users review all discovered endpoints at once with `openshell policy draft get`, approve them, and export the learned policy. Filesystem (Landlock) restrictions are skipped in permissive mode. Seccomp, process identity, and SSRF guards remain enforced. Permissive mode is threaded through all enforcement paths: CONNECT L4, forward-proxy L4, single-route and multi-route L7 relay, forward-proxy L7 path selection, and WebSocket/GraphQL message-level inspection. The permissive flag flows from SandboxSpec (proto) through the gateway's GetSandboxConfigResponse to the sandbox supervisor, which sets it on ProxyPolicy and L7EvalContext. Closes #1839 Signed-off-by: Russell Bryant --- crates/openshell-cli/src/main.rs | 10 + crates/openshell-cli/src/run.rs | 25 + crates/openshell-core/src/grpc_client.rs | 3 + crates/openshell-core/src/policy.rs | 7 +- crates/openshell-sandbox/src/lib.rs | 31 +- crates/openshell-server/src/grpc/policy.rs | 3 + .../src/l7/graphql.rs | 2 + .../src/l7/relay.rs | 119 ++- .../src/l7/token_grant_injection.rs | 4 + .../src/l7/websocket.rs | 36 +- .../openshell-supervisor-network/src/proxy.rs | 817 +++++++++++------- .../src/process.rs | 10 +- .../openshell-supervisor-process/src/run.rs | 1 + .../src/sandbox/linux/mod.rs | 8 + .../openshell-supervisor-process/src/ssh.rs | 16 +- docs/sandboxes/policies.mdx | 92 ++ e2e/python/test_sandbox_policy.py | 368 +++++++- proto/openshell.proto | 6 + proto/sandbox.proto | 2 + 19 files changed, 1209 insertions(+), 351 deletions(-) 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; }