From 69ed2d0d1f0a24022c1114afb59c33bcecf6f11f 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 | 23 + .../sandbox_create_lifecycle_integration.rs | 12 + crates/openshell-sandbox/src/grpc_client.rs | 42 +- crates/openshell-sandbox/src/l7/graphql.rs | 2 + crates/openshell-sandbox/src/l7/relay.rs | 105 +++- crates/openshell-sandbox/src/l7/websocket.rs | 36 +- crates/openshell-sandbox/src/lib.rs | 31 +- crates/openshell-sandbox/src/policy.rs | 7 +- crates/openshell-sandbox/src/process.rs | 6 +- crates/openshell-sandbox/src/proxy.rs | 579 +++++++++++------- .../src/sandbox/linux/mod.rs | 8 + crates/openshell-sandbox/src/ssh.rs | 12 +- crates/openshell-server/src/grpc/policy.rs | 3 + docs/sandboxes/policies.mdx | 92 +++ e2e/python/test_sandbox_policy.py | 368 ++++++++++- proto/openshell.proto | 6 + proto/sandbox.proto | 2 + 18 files changed, 1093 insertions(+), 251 deletions(-) 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; }