diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 19fe2df83..9d2b579e5 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1277,6 +1277,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 @@ -2565,6 +2573,7 @@ async fn main() -> Result<()> { no_auto_providers, labels, envs, + permissive, approval_mode, command, } => { @@ -2651,6 +2660,7 @@ async fn main() -> Result<()> { &labels_map, &env_map, &approval_mode, + permissive, &tls, )) .await?; diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 7290ac05e..262567be3 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -1739,6 +1739,7 @@ pub async fn sandbox_create( labels: &HashMap, environment: &HashMap, approval_mode: &str, + permissive: bool, tls: &TlsOptions, ) -> Result<()> { if editor.is_some() && !command.is_empty() { @@ -1798,6 +1799,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) @@ -1822,6 +1844,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-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index be71c0a36..00c4fcdbb 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -797,6 +797,7 @@ async fn sandbox_create_keeps_command_sessions_by_default() { &HashMap::new(), &HashMap::new(), "manual", + false, &tls, ) .await @@ -841,6 +842,7 @@ async fn sandbox_create_sends_cpu_and_memory_limits_only() { &HashMap::new(), &HashMap::new(), "manual", + false, &tls, ) .await @@ -919,6 +921,7 @@ async fn sandbox_create_sends_driver_config_json() { &HashMap::new(), &HashMap::new(), "manual", + false, &tls, ) .await @@ -994,6 +997,7 @@ async fn sandbox_create_does_not_infer_command_providers_when_v2_enabled() { &HashMap::new(), &HashMap::new(), "manual", + false, &tls, ) .await @@ -1053,6 +1057,7 @@ async fn sandbox_create_returns_vm_error_without_waiting_for_timeout() { &HashMap::new(), &HashMap::new(), "manual", + false, &tls, ) .await @@ -1108,6 +1113,7 @@ async fn sandbox_create_keeps_waiting_while_vm_progress_arrives() { &HashMap::new(), &HashMap::new(), "manual", + false, &tls, ) .await @@ -1155,6 +1161,7 @@ async fn sandbox_create_times_out_when_only_logs_arrive() { &HashMap::new(), &HashMap::new(), "manual", + false, &tls, ) .await @@ -1198,6 +1205,7 @@ async fn sandbox_create_deletes_command_sessions_with_no_keep() { &HashMap::new(), &HashMap::new(), "manual", + false, &tls, ) .await @@ -1245,6 +1253,7 @@ async fn sandbox_create_deletes_shell_sessions_with_no_keep() { &HashMap::new(), &HashMap::new(), "manual", + false, &tls, ) .await @@ -1292,6 +1301,7 @@ async fn sandbox_create_keeps_sandbox_with_hidden_keep_flag() { &HashMap::new(), &HashMap::new(), "manual", + false, &tls, ) .await @@ -1339,6 +1349,7 @@ async fn sandbox_create_keeps_sandbox_with_forwarding() { &HashMap::new(), &HashMap::new(), "manual", + false, &tls, ) .await @@ -1382,6 +1393,7 @@ async fn sandbox_create_sends_environment_variables() { &HashMap::new(), &env_map, "manual", + false, &tls, ) .await diff --git a/crates/openshell-sandbox/src/grpc_client.rs b/crates/openshell-sandbox/src/grpc_client.rs index ffa22f951..c6aa6c3e3 100644 --- a/crates/openshell-sandbox/src/grpc_client.rs +++ b/crates/openshell-sandbox/src/grpc_client.rs @@ -546,21 +546,6 @@ async fn connect_inference(endpoint: &str) -> Result Result> { - debug!(endpoint = %endpoint, sandbox_id = %sandbox_id, "Connecting to OpenShell server"); - - let mut client = connect(endpoint).await?; - - debug!("Connected, fetching sandbox policy"); - - fetch_policy_with_client(&mut client, sandbox_id).await -} - /// Fetch sandbox policy using an existing client connection. async fn fetch_policy_with_client( client: &mut OpenShellClient, @@ -585,6 +570,33 @@ async fn fetch_policy_with_client( })?)) } +/// Fetch sandbox policy and permissive flag from the config response. +pub async fn fetch_policy_and_permissive( + endpoint: &str, + sandbox_id: &str, +) -> Result<(Option, bool)> { + let mut client = connect(endpoint).await?; + + let response = client + .get_sandbox_config(GetSandboxConfigRequest { + sandbox_id: sandbox_id.to_string(), + }) + .await + .into_diagnostic()?; + + let inner = response.into_inner(); + let permissive = inner.permissive; + + if inner.version == 0 && inner.policy.is_none() { + return Ok((None, permissive)); + } + + let policy = inner + .policy + .ok_or_else(|| miette::miette!("Server returned non-zero version but empty policy"))?; + Ok((Some(policy), permissive)) +} + /// Sync a locally-discovered policy using an existing client connection. async fn sync_policy_with_client( client: &mut OpenShellClient, diff --git a/crates/openshell-sandbox/src/l7/graphql.rs b/crates/openshell-sandbox/src/l7/graphql.rs index 2ff502d1c..a95c7fb91 100644 --- a/crates/openshell-sandbox/src/l7/graphql.rs +++ b/crates/openshell-sandbox/src/l7/graphql.rs @@ -802,6 +802,8 @@ network_policies: cmdline_paths: Vec::new(), secret_resolver: None, activity_tx: None, + permissive: false, + denial_tx: None, }; let request_info = crate::l7::L7RequestInfo { action: req.action, diff --git a/crates/openshell-sandbox/src/l7/relay.rs b/crates/openshell-sandbox/src/l7/relay.rs index 9efa7ca9f..368ba1ea2 100644 --- a/crates/openshell-sandbox/src/l7/relay.rs +++ b/crates/openshell-sandbox/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. @@ -40,6 +41,10 @@ pub struct L7EvalContext { pub(crate) secret_resolver: Option>, /// Anonymous activity counter channel. pub(crate) activity_tx: 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)] @@ -217,6 +222,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(crate::denial_aggregator::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, @@ -344,7 +375,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(crate::denial_aggregator::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, @@ -768,7 +819,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(crate::denial_aggregator::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 { // Forward request to upstream and relay response let outcome = crate::l7::rest::relay_http_request_with_options_guarded( &req, @@ -1000,7 +1067,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(crate::denial_aggregator::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, @@ -1383,6 +1470,8 @@ network_policies: cmdline_paths: vec![], secret_resolver: None, activity_tx: None, + permissive: false, + denial_tx: None, }; let request = L7RequestInfo { action: "WEBSOCKET_TEXT".into(), @@ -1439,6 +1528,8 @@ network_policies: cmdline_paths: vec![], secret_resolver: None, activity_tx: None, + permissive: false, + denial_tx: None, }; let (mut app, mut relay_client) = tokio::io::duplex(8192); @@ -1544,6 +1635,8 @@ network_policies: cmdline_paths: vec![], secret_resolver: resolver.map(Arc::new), activity_tx: None, + permissive: false, + denial_tx: None, }; let (mut app, mut relay_client) = tokio::io::duplex(8192); @@ -1662,6 +1755,8 @@ network_policies: cmdline_paths: vec![], secret_resolver: resolver.map(Arc::new), activity_tx: None, + permissive: false, + denial_tx: None, }; let (mut app, mut relay_client) = tokio::io::duplex(8192); @@ -1833,6 +1928,8 @@ network_policies: cmdline_paths: vec![], secret_resolver: None, activity_tx: None, + permissive: false, + denial_tx: None, }; let (mut app, mut relay_client) = tokio::io::duplex(8192); @@ -1921,6 +2018,8 @@ network_policies: cmdline_paths: vec![], secret_resolver: None, activity_tx: None, + permissive: false, + denial_tx: None, }; let (mut app, mut relay_client) = tokio::io::duplex(8192); diff --git a/crates/openshell-sandbox/src/l7/websocket.rs b/crates/openshell-sandbox/src/l7/websocket.rs index 89a6e6c51..59cf38fc7 100644 --- a/crates/openshell-sandbox/src/l7/websocket.rs +++ b/crates/openshell-sandbox/src/l7/websocket.rs @@ -562,7 +562,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(crate::denial_aggregator::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(()) } @@ -630,7 +645,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(crate::denial_aggregator::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(()) } @@ -1271,6 +1301,8 @@ network_policies: cmdline_paths: vec![], secret_resolver: None, activity_tx: 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-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index fa4654243..7abd39dda 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -348,7 +348,7 @@ pub async fn run_sandbox( // Load policy and initialize OPA engine let openshell_endpoint_for_proxy = openshell_endpoint.clone(); let sandbox_name_for_agg = sandbox.clone(); - let (policy, opa_engine, retained_proto) = load_policy( + let (mut policy, opa_engine, retained_proto, permissive) = load_policy( sandbox_id.clone(), sandbox, openshell_endpoint.clone(), @@ -356,6 +356,14 @@ pub async fn run_sandbox( policy_data, ) .await?; + + if 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"); + } + let policy_local_ctx = Arc::new(policy_local::PolicyLocalContext::new( retained_proto.clone(), openshell_endpoint.clone(), @@ -1955,7 +1963,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(), @@ -2086,6 +2097,7 @@ async fn load_policy( SandboxPolicy, Option>, Option, + bool, // permissive )> { // File mode: load OPA engine from rego rules + YAML data (dev override) if let (Some(policy_file), Some(data_file)) = (&policy_rules, &policy_data) { @@ -2109,13 +2121,16 @@ 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, }; enrich_sandbox_baseline_paths(&mut policy); - return Ok((policy, Some(Arc::new(engine)), None)); + return Ok((policy, Some(Arc::new(engine)), None, false)); } // gRPC mode: fetch typed proto policy, construct OPA engine from baked rules + proto data @@ -2125,8 +2140,10 @@ async fn load_policy( endpoint = %endpoint, "Fetching sandbox policy via gRPC" ); - let proto_policy = - grpc_retry("Policy fetch", || grpc_client::fetch_policy(endpoint, id)).await?; + let (proto_policy, permissive) = grpc_retry("Policy fetch", || { + grpc_client::fetch_policy_and_permissive(endpoint, id) + }) + .await?; let mut proto_policy = if let Some(p) = proto_policy { p @@ -2185,7 +2202,7 @@ async fn load_policy( let opa_engine = Some(Arc::new(OpaEngine::from_proto(&proto_policy)?)); let policy = SandboxPolicy::try_from(proto_policy.clone())?; - return Ok((policy, opa_engine, Some(proto_policy))); + return Ok((policy, opa_engine, Some(proto_policy), permissive)); } // No policy source available diff --git a/crates/openshell-sandbox/src/policy.rs b/crates/openshell-sandbox/src/policy.rs index 0827fa0d0..ba04cc748 100644 --- a/crates/openshell-sandbox/src/policy.rs +++ b/crates/openshell-sandbox/src/policy.rs @@ -68,6 +68,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)] @@ -103,7 +105,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/process.rs b/crates/openshell-sandbox/src/process.rs index 76786a84d..22bd6c8de 100644 --- a/crates/openshell-sandbox/src/process.rs +++ b/crates/openshell-sandbox/src/process.rs @@ -267,8 +267,12 @@ impl ProcessHandle { // This MUST happen before drop_privileges() so that root-only paths // (e.g. mode 700 directories) can be opened. See issue #803. #[cfg(target_os = "linux")] - let prepared_sandbox = sandbox::linux::prepare(policy, workdir) + let mut prepared_sandbox = sandbox::linux::prepare(policy, workdir) .map_err(|err| miette::miette!("Failed to prepare sandbox: {err}"))?; + #[cfg(target_os = "linux")] + if policy.network.proxy.as_ref().is_some_and(|p| p.permissive) { + prepared_sandbox.skip_landlock(); + } // Set up process group for signal handling (non-interactive mode only). // In interactive mode, we inherit the parent's process group to maintain diff --git a/crates/openshell-sandbox/src/proxy.rs b/crates/openshell-sandbox/src/proxy.rs index aa8338433..a12875b2b 100644 --- a/crates/openshell-sandbox/src/proxy.rs +++ b/crates/openshell-sandbox/src/proxy.rs @@ -228,6 +228,7 @@ impl ProxyHandle { ); } + let permissive = policy.permissive; let join = tokio::spawn(async move { loop { match listener.accept().await { @@ -257,6 +258,7 @@ impl ProxyHandle { resolver, dtx, atx, + permissive, ) .await { @@ -413,6 +415,7 @@ async fn handle_tcp_connection( secret_resolver: Option>, denial_tx: Option>, activity_tx: Option, + permissive: bool, ) -> Result<()> { let mut buf = vec![0u8; MAX_HEADER_BYTES]; let mut used = 0usize; @@ -460,6 +463,7 @@ async fn handle_tcp_connection( secret_resolver, denial_tx.as_ref(), activity_tx.as_ref(), + permissive, ) .await; } @@ -557,44 +561,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(crate::ocsf_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(crate::ocsf_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(crate::ocsf_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(()); + } } let sandbox_entrypoint_pid = entrypoint_pid.load(Ordering::Acquire); @@ -896,7 +931,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, @@ -953,6 +989,8 @@ async fn handle_tcp_connection( .collect(), secret_resolver: secret_resolver.clone(), activity_tx: activity_tx.clone(), + permissive, + denial_tx: denial_tx.clone(), }; if effective_tls_skip { @@ -1937,16 +1975,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; @@ -2931,6 +2979,7 @@ async fn handle_forward_proxy( secret_resolver: Option>, 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 @@ -3058,11 +3107,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(crate::ocsf_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(crate::ocsf_ctx()) .activity(ActivityId::Other) .action(ActionId::Denied) @@ -3083,28 +3166,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("-"); @@ -3158,13 +3241,16 @@ async fn handle_forward_proxy( .collect(), secret_resolver: secret_resolver.clone(), activity_tx: activity_tx.cloned(), + 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() { @@ -3265,142 +3351,170 @@ 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(()); - }; - 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(crate::ocsf_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}"), - ), - ) - .await?; - return Ok(()); + 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()), + }); } - }; - forward_request_bytes = graphql_request.raw_header; - Some(info) - } else { - None - }; - let request_info = crate::l7::L7RequestInfo { - action: method.to_string(), - target: path.clone(), - query_params, - graphql, - }; - - let parse_error_reason = request_info - .graphql - .as_ref() - .and_then(|info| info.error.as_deref()) - .map(|error| format!("GraphQL request rejected: {error}")); - 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| { + 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" + ), + ), + ) + .await?; + return Ok(()); + } + } + if let Some(l7_config) = l7_config { + 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(crate::ocsf_ctx()) .activity(ActivityId::Fail) - .severity(SeverityId::Low) + .severity(SeverityId::Medium) .status(StatusId::Failure) .dst_endpoint(Endpoint::from_domain(&host_lc, port)) - .message(format!("L7 eval failed, denying request: {e}")) + .message(format!("FORWARD_GRAPHQL_L7 request rejected: {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, - ), - }; - let engine_type = if l7_config.config.protocol == crate::l7::L7Protocol::Graphql { - "l7-graphql" + 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}"), + ), + ) + .await?; + return Ok(()); + } + }; + forward_request_bytes = graphql_request.raw_header; + Some(info) } else { - "l7" + None }; - let message_prefix = if l7_config.config.protocol == crate::l7::L7Protocol::Graphql { - "FORWARD_GRAPHQL_L7" - } else { - "FORWARD_L7" + let request_info = crate::l7::L7RequestInfo { + action: method.to_string(), + target: path.clone(), + query_params, + graphql, }; - let event = HttpActivityBuilder::new(crate::ocsf_ctx()) + + let parse_error_reason = request_info + .graphql + .as_ref() + .and_then(|info| info.error.as_deref()) + .map(|error| format!("GraphQL request rejected: {error}")); + 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(crate::ocsf_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, + ), + }; + let engine_type = if l7_config.config.protocol == crate::l7::L7Protocol::Graphql { + "l7-graphql" + } else { + "l7" + }; + let message_prefix = if l7_config.config.protocol == crate::l7::L7Protocol::Graphql + { + "FORWARD_GRAPHQL_L7" + } else { + "FORWARD_L7" + }; + let event = HttpActivityBuilder::new(crate::ocsf_ctx()) .activity(ActivityId::Other) .action(action_id) .disposition(disposition_id) @@ -3420,37 +3534,58 @@ async fn handle_forward_proxy( "{message_prefix} {decision_str} {method} {host_lc}:{port}{path} reason={reason}" )) .build(); - ocsf_emit!(event); - } + ocsf_emit!(event); + } - let effectively_denied = force_deny - || (!allowed && l7_config.config.enforcement == crate::l7::EnforcementMode::Enforce); + let l7_would_deny = 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 l7_would_deny && !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 l7_would_deny && 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). @@ -4215,6 +4350,8 @@ mod tests { cmdline_paths: vec![], secret_resolver: None, activity_tx: None, + permissive: false, + denial_tx: None, }; (config, tunnel_engine, ctx) } @@ -4381,6 +4518,8 @@ mod tests { cmdline_paths: vec![], secret_resolver: resolver, activity_tx: None, + permissive: false, + denial_tx: None, }; let query_params = std::collections::HashMap::new(); @@ -4422,6 +4561,8 @@ mod tests { cmdline_paths: vec![], secret_resolver: None, activity_tx: None, + permissive: false, + denial_tx: None, }; let query_params = std::collections::HashMap::new(); let config = websocket_l7_config(crate::l7::L7Protocol::Rest, false); diff --git a/crates/openshell-sandbox/src/sandbox/linux/mod.rs b/crates/openshell-sandbox/src/sandbox/linux/mod.rs index a3a32c77a..569e71ce2 100644 --- a/crates/openshell-sandbox/src/sandbox/linux/mod.rs +++ b/crates/openshell-sandbox/src/sandbox/linux/mod.rs @@ -20,6 +20,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-sandbox/src/ssh.rs b/crates/openshell-sandbox/src/ssh.rs index 9db2bf97d..db04a2a90 100644 --- a/crates/openshell-sandbox/src/ssh.rs +++ b/crates/openshell-sandbox/src/ssh.rs @@ -799,8 +799,12 @@ fn spawn_pty_shell( // Phase 1 (as root): Prepare Landlock ruleset before drop_privileges. #[cfg(target_os = "linux")] - let prepared_sandbox = sandbox::linux::prepare(policy, workdir.as_deref()) + let mut prepared_sandbox = sandbox::linux::prepare(policy, workdir.as_deref()) .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) { + prepared_sandbox.skip_landlock(); + } #[cfg(unix)] { @@ -948,8 +952,12 @@ fn spawn_pipe_exec( // Phase 1 (as root): Prepare Landlock ruleset before drop_privileges. #[cfg(target_os = "linux")] - let prepared_sandbox = sandbox::linux::prepare(policy, workdir.as_deref()) + let mut prepared_sandbox = sandbox::linux::prepare(policy, workdir.as_deref()) .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) { + prepared_sandbox.skip_landlock(); + } #[cfg(unix)] { diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 380671f10..686f1f9f4 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -1221,6 +1221,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, @@ -1230,6 +1232,7 @@ pub(super) async fn handle_get_sandbox_config( policy_source: policy_source.into(), global_policy_version, provider_env_revision, + permissive, })) } diff --git a/docs/sandboxes/policies.mdx b/docs/sandboxes/policies.mdx index 406ed12b8..054a0464e 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 6d797b49b..a6dd97ba6 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 @@ -2049,3 +2049,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 c2755aaf7..13cb31d94 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -329,6 +329,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; } // Public sandbox template mapped onto compute-driver template inputs. diff --git a/proto/sandbox.proto b/proto/sandbox.proto index ef0b0540f..1bd14cbcc 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -272,4 +272,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; }