diff --git a/crates/cli/lib/commands/common.rs b/crates/cli/lib/commands/common.rs index 6b471eef..7c81d3bc 100644 --- a/crates/cli/lib/commands/common.rs +++ b/crates/cli/lib/commands/common.rs @@ -272,7 +272,7 @@ pub struct SandboxOpts { #[arg(long)] pub secret: Vec, - /// Action when a secret is sent to a disallowed host (block, block-and-log, block-and-terminate). + /// Action when a secret is sent to a disallowed host (block, block-and-log, block-and-terminate, passthrough). #[cfg(feature = "net")] #[arg(long)] pub on_secret_violation: Option, @@ -712,7 +712,9 @@ fn apply_network_opts( n = n.trust_host_cas(true); } if let Some(action) = violation_action { - n = n.on_secret_violation(action); + n = n.on_secret_violation(|_| { + microsandbox_network::builder::ViolationActionBuilder::from_action(action) + }); } // TLS configuration. @@ -961,14 +963,15 @@ fn parse_secret(spec: &str) -> anyhow::Result<(String, String, String)> { fn parse_violation_action( s: &Option, ) -> anyhow::Result> { - use microsandbox_network::secrets::config::ViolationAction; + use microsandbox_network::secrets::config::{HostPattern, ViolationAction}; match s.as_deref() { None => Ok(None), Some("block") => Ok(Some(ViolationAction::Block)), Some("block-and-log") => Ok(Some(ViolationAction::BlockAndLog)), Some("block-and-terminate") => Ok(Some(ViolationAction::BlockAndTerminate)), + Some("passthrough") => Ok(Some(ViolationAction::Passthrough(vec![HostPattern::Any]))), Some(other) => anyhow::bail!( - "invalid violation action: {other} (expected: block, block-and-log, block-and-terminate)" + "invalid violation action: {other} (expected: block, block-and-log, block-and-terminate, passthrough)" ), } } @@ -1253,6 +1256,19 @@ mod tests { use super::*; + #[cfg(feature = "net")] + #[test] + fn parse_violation_action_accepts_passthrough() { + let action = parse_violation_action(&Some("passthrough".to_string())) + .expect("passthrough should parse") + .expect("action should be present"); + + assert!(matches!( + action, + microsandbox_network::secrets::config::ViolationAction::Passthrough(_) + )); + } + //---------------------------------------------------------------------------------------------- // Tests: apply_volume / -v parser //---------------------------------------------------------------------------------------------- diff --git a/crates/network/lib/builder.rs b/crates/network/lib/builder.rs index 8bc5b947..69410b69 100644 --- a/crates/network/lib/builder.rs +++ b/crates/network/lib/builder.rs @@ -49,9 +49,16 @@ pub struct SecretBuilder { placeholder: Option, allowed_hosts: Vec, injection: SecretInjection, + on_violation: Option, require_tls_identity: bool, } +/// Fluent builder for a [`ViolationAction`]. +#[derive(Default)] +pub struct ViolationActionBuilder { + action: ViolationAction, +} + //-------------------------------------------------------------------------------------------------- // Methods //-------------------------------------------------------------------------------------------------- @@ -179,14 +186,18 @@ impl NetworkBuilder { placeholder: placeholder.into(), allowed_hosts: vec![HostPattern::Exact(allowed_host.into())], injection: SecretInjection::default(), + on_violation: None, require_tls_identity: true, }); self } /// Set the violation action for secrets. - pub fn on_secret_violation(mut self, action: ViolationAction) -> Self { - self.config.secrets.on_violation = action; + pub fn on_secret_violation( + mut self, + f: impl FnOnce(ViolationActionBuilder) -> ViolationActionBuilder, + ) -> Self { + self.config.secrets.on_violation = f(ViolationActionBuilder::default()).build(); self } @@ -371,6 +382,7 @@ impl SecretBuilder { placeholder: None, allowed_hosts: Vec::new(), injection: SecretInjection::default(), + on_violation: None, require_tls_identity: true, } } @@ -416,6 +428,15 @@ impl SecretBuilder { self } + /// Set the violation action for this secret. + pub fn on_violation( + mut self, + f: impl FnOnce(ViolationActionBuilder) -> ViolationActionBuilder, + ) -> Self { + self.on_violation = Some(f(ViolationActionBuilder::default()).build()); + self + } + /// Require verified TLS identity before substituting (default: true). pub fn require_tls_identity(mut self, enabled: bool) -> Self { self.require_tls_identity = enabled; @@ -463,11 +484,75 @@ impl SecretBuilder { placeholder, allowed_hosts: self.allowed_hosts, injection: self.injection, + on_violation: self.on_violation, require_tls_identity: self.require_tls_identity, } } } +impl ViolationActionBuilder { + /// Start building a violation action. + pub fn new() -> Self { + Self::default() + } + + /// Start building from an existing action. + pub fn from_action(action: ViolationAction) -> Self { + action.into() + } + + /// Block the request silently. + pub fn block(mut self) -> Self { + self.action = ViolationAction::Block; + self + } + + /// Block the request and emit a warning log. + pub fn block_and_log(mut self) -> Self { + self.action = ViolationAction::BlockAndLog; + self + } + + /// Block the request and terminate the sandbox. + pub fn block_and_terminate(mut self) -> Self { + self.action = ViolationAction::BlockAndTerminate; + self + } + + /// Allow a host to receive secret placeholders without substitution. + pub fn passthrough_host(mut self, host: impl Into) -> Self { + self.push_passthrough_host(HostPattern::Exact(host.into())); + self + } + + /// Allow hosts matching a wildcard pattern to receive secret placeholders without substitution. + pub fn passthrough_host_pattern(mut self, pattern: impl Into) -> Self { + self.push_passthrough_host(HostPattern::Wildcard(pattern.into())); + self + } + + /// Allow any host to receive secret placeholders without substitution. + pub fn passthrough_all_hosts(mut self, i_understand_the_risk: bool) -> Self { + if i_understand_the_risk { + self.push_passthrough_host(HostPattern::Any); + } + self + } + + /// Helper to accumulate passthrough hosts into the current action. + fn push_passthrough_host(&mut self, host: HostPattern) { + match self.action { + ViolationAction::Passthrough(ref mut hosts) => hosts.push(host), + _ => self.action = ViolationAction::Passthrough(vec![host]), + } + } + + /// Consume the builder and return the action. + pub fn build(self) -> ViolationAction { + self.action + } +} + //-------------------------------------------------------------------------------------------------- // Trait Implementations //-------------------------------------------------------------------------------------------------- @@ -489,6 +574,11 @@ impl Default for SecretBuilder { Self::new() } } +impl From for ViolationActionBuilder { + fn from(action: ViolationAction) -> Self { + Self { action } + } +} //-------------------------------------------------------------------------------------------------- // Tests @@ -524,4 +614,75 @@ mod tests { assert_eq!(cfg.ports[1].host_bind, bind); assert_eq!(cfg.ports[1].protocol, PortProtocol::Udp); } + + #[test] + fn network_builder_sets_global_passthrough_action() { + let cfg = NetworkBuilder::new() + .on_secret_violation(|v| { + v.passthrough_host("api.anthropic.com") + .passthrough_host_pattern("*.anthropic.com") + }) + .build() + .unwrap(); + + assert_eq!( + cfg.secrets.on_violation, + ViolationAction::Passthrough(vec![ + HostPattern::Exact("api.anthropic.com".into()), + HostPattern::Wildcard("*.anthropic.com".into()), + ]) + ); + } + + #[test] + fn secret_builder_sets_violation_action() { + let secret = SecretBuilder::new() + .env("TOKEN") + .value("secret-value") + .allow_host("api.github.com") + .on_violation(|v| { + v.passthrough_host("api.anthropic.com") + .passthrough_host_pattern("*.anthropic.com") + }) + .build(); + + assert_eq!( + secret.on_violation, + Some(ViolationAction::Passthrough(vec![ + HostPattern::Exact("api.anthropic.com".into()), + HostPattern::Wildcard("*.anthropic.com".into()), + ])), + ); + } + + #[test] + fn violation_action_builder_blocking_call_replaces_passthrough_policy() { + let action = ViolationActionBuilder::default() + .passthrough_host("google.com") + .block_and_terminate() + .passthrough_host("facebook.com") + .build(); + + assert_eq!( + action, + ViolationAction::Passthrough(vec![HostPattern::Exact("facebook.com".into())]) + ); + } + + #[test] + fn violation_action_builder_accumulates_passthrough_hosts() { + let action = ViolationActionBuilder::default() + .block() + .passthrough_host("google.com") + .passthrough_host("facebook.com") + .build(); + + assert_eq!( + action, + ViolationAction::Passthrough(vec![ + HostPattern::Exact("google.com".into()), + HostPattern::Exact("facebook.com".into()), + ]), + ); + } } diff --git a/crates/network/lib/secrets/config.rs b/crates/network/lib/secrets/config.rs index 956e2b92..73eccb44 100644 --- a/crates/network/lib/secrets/config.rs +++ b/crates/network/lib/secrets/config.rs @@ -38,6 +38,10 @@ pub struct SecretEntry { #[serde(default)] pub injection: SecretInjection, + /// Action on secret violation for this secret. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub on_violation: Option, + /// Require verified TLS identity before substituting (default: true). /// When true, secret is only substituted if the connection uses TLS /// interception (not bypass) and the SNI matches an allowed host. @@ -46,13 +50,17 @@ pub struct SecretEntry { } /// Host pattern for secret allowlist. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] pub enum HostPattern { /// Exact hostname match. + #[serde(alias = "Exact")] Exact(String), /// Wildcard match (e.g., `*.openai.com`). + #[serde(alias = "Wildcard")] Wildcard(String), /// Any host (dangerous — secret can be exfiltrated). + #[serde(alias = "Any")] Any, } @@ -77,15 +85,22 @@ pub struct SecretInjection { } /// Action when a secret placeholder is detected going to a disallowed host. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] pub enum ViolationAction { /// Block the request silently. + #[serde(alias = "Block")] Block, /// Block and log (default). #[default] + #[serde(alias = "BlockAndLog", alias = "block_and_log")] BlockAndLog, /// Block and terminate the sandbox. + #[serde(alias = "BlockAndTerminate", alias = "block_and_terminate")] BlockAndTerminate, + /// Forward the request with the placeholder unchanged for matching hosts. + #[serde(alias = "Passthrough")] + Passthrough(Vec), } //-------------------------------------------------------------------------------------------------- @@ -100,6 +115,7 @@ impl std::fmt::Debug for SecretEntry { .field("placeholder", &self.placeholder) .field("allowed_hosts", &self.allowed_hosts) .field("injection", &self.injection) + .field("on_violation", &self.on_violation) .field("require_tls_identity", &self.require_tls_identity) .finish() } @@ -199,8 +215,46 @@ mod tests { placeholder: "$K".into(), allowed_hosts: vec![], injection: SecretInjection::default(), + on_violation: None, require_tls_identity: true, }; assert!(entry.require_tls_identity); } + + #[test] + fn violation_action_serializes_with_sdk_casing() { + let action = ViolationAction::Passthrough(vec![ + HostPattern::Exact("api.anthropic.com".into()), + HostPattern::Wildcard("*.anthropic.com".into()), + HostPattern::Any, + ]); + + assert_eq!( + serde_json::to_string(&action).unwrap(), + r#"{"passthrough":[{"exact":"api.anthropic.com"},{"wildcard":"*.anthropic.com"},"any"]}"# + ); + assert_eq!( + serde_json::to_string(&ViolationAction::BlockAndLog).unwrap(), + r#""block-and-log""# + ); + assert_eq!( + serde_json::to_string(&ViolationAction::BlockAndTerminate).unwrap(), + r#""block-and-terminate""# + ); + } + + #[test] + fn violation_action_accepts_legacy_pascal_case() { + let action: ViolationAction = + serde_json::from_str(r#"{"Passthrough":[{"Exact":"api.anthropic.com"}]}"#).unwrap(); + + assert_eq!( + action, + ViolationAction::Passthrough(vec![HostPattern::Exact("api.anthropic.com".into())]) + ); + assert_eq!( + serde_json::from_str::(r#""BlockAndTerminate""#).unwrap(), + ViolationAction::BlockAndTerminate + ); + } } diff --git a/crates/network/lib/secrets/handler.rs b/crates/network/lib/secrets/handler.rs index aebf74b4..be7fb1b2 100644 --- a/crates/network/lib/secrets/handler.rs +++ b/crates/network/lib/secrets/handler.rs @@ -20,13 +20,9 @@ use super::config::{SecretsConfig, ViolationAction}; /// secrets are eligible for this connection based on host matching. pub struct SecretsHandler { /// Secrets eligible for substitution on this connection. - eligible: Vec, - /// All placeholder strings (for violation detection on disallowed hosts). - all_placeholders: Vec, - /// Violation action. - on_violation: ViolationAction, - /// Whether any ineligible secrets exist (pre-computed for fast-path skip). - has_ineligible: bool, + eligible_for_substitution: Vec, + /// Secret placeholders that should trigger an effective blocking action. + ineligible_for_substitution: Vec, /// Whether this connection is TLS-intercepted (not bypass). tls_intercepted: bool, /// Longest placeholder length. Sizes the sliding-window tail. @@ -48,6 +44,21 @@ struct EligibleSecret { require_tls_identity: bool, } +/// A secret that did not pass substitution or passthrough host matching. +struct IneligibleSecret { + placeholder: String, + action: BlockingAction, +} + +/// Blocking action to take when an ineligible placeholder is detected. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +enum BlockingAction { + Block, + #[default] + BlockAndLog, + BlockAndTerminate, +} + //-------------------------------------------------------------------------------------------------- // Methods //-------------------------------------------------------------------------------------------------- @@ -113,6 +124,25 @@ impl EligibleSecret { } } +impl BlockingAction { + fn from_violation_action(action: &ViolationAction) -> Option { + match action { + ViolationAction::Block => Some(Self::Block), + ViolationAction::BlockAndLog => Some(Self::BlockAndLog), + ViolationAction::BlockAndTerminate => Some(Self::BlockAndTerminate), + ViolationAction::Passthrough(_) => None, + } + } + + fn into_violation_action(self) -> ViolationAction { + match self { + Self::Block => ViolationAction::Block, + Self::BlockAndLog => ViolationAction::BlockAndLog, + Self::BlockAndTerminate => ViolationAction::BlockAndTerminate, + } + } +} + impl SecretsHandler { /// Create a handler for a specific connection. /// @@ -121,17 +151,20 @@ impl SecretsHandler { /// `tls_intercepted` indicates whether this is a MITM connection /// (true) or a bypass/plain connection (false). pub fn new(config: &SecretsConfig, sni: &str, tls_intercepted: bool) -> Self { - let mut eligible = Vec::new(); - let mut all_placeholders = Vec::new(); + let mut eligible_for_substitution = Vec::new(); + let mut ineligible_for_substitution = Vec::new(); + let mut max_placeholder_len = 0; for secret in &config.secrets { - all_placeholders.push(secret.placeholder.clone()); + max_placeholder_len = max_placeholder_len.max(secret.placeholder.len()); let host_allowed = secret.allowed_hosts.is_empty() || secret.allowed_hosts.iter().any(|p| p.matches(sni)); + // If the SNI matches an allowed host for this secret, add it to the + // eligible list for substitution, and skip violation checks for this secret. if host_allowed { - eligible.push(EligibleSecret { + eligible_for_substitution.push(EligibleSecret { placeholder: secret.placeholder.clone(), value: secret.value.clone(), inject_headers: secret.injection.headers, @@ -140,17 +173,29 @@ impl SecretsHandler { inject_body: secret.injection.body, require_tls_identity: secret.require_tls_identity, }); + + continue; } - } - let has_ineligible = eligible.len() < all_placeholders.len(); - let max_placeholder_len = all_placeholders.iter().map(String::len).max().unwrap_or(0); + let action = secret.on_violation.as_ref().unwrap_or(&config.on_violation); + + // Passthrough means the placeholder can be forwarded unchanged to this SNI. + if let ViolationAction::Passthrough(hosts) = action + && hosts.iter().any(|p| p.matches(sni)) + { + continue; + } + + // Non-matching passthrough policies fall back to the default blocking action. + ineligible_for_substitution.push(IneligibleSecret { + placeholder: secret.placeholder.clone(), + action: BlockingAction::from_violation_action(action).unwrap_or_default(), + }); + } Self { - eligible, - all_placeholders, - on_violation: config.on_violation.clone(), - has_ineligible, + eligible_for_substitution, + ineligible_for_substitution, tls_intercepted, max_placeholder_len, prev_tail: Vec::new(), @@ -165,9 +210,9 @@ impl SecretsHandler { /// - `query_params`: substitutes in the request line (first line, query portion) /// - `body`: substitutes in the body portion (after boundary) /// - /// Returns `None` if a violation is detected (placeholder going to a - /// disallowed host) or `BlockAndTerminate` is triggered. - pub fn substitute<'a>(&mut self, data: &'a [u8]) -> Option> { + /// Returns the violation action if a placeholder is detected going to a + /// disallowed host. + pub fn substitute<'a>(&mut self, data: &'a [u8]) -> Result, ViolationAction> { // Split raw bytes at the header boundary BEFORE converting to owned strings. // This avoids position shifts from from_utf8_lossy replacement chars. let boundary = find_header_boundary(data); @@ -182,31 +227,30 @@ impl SecretsHandler { String::new() }; - // Fast path: skip violation check when no ineligible secrets exist. - if self.has_ineligible && self.has_violation(data, &header_str) { - self.update_tail(data); - match self.on_violation { - ViolationAction::Block => return None, - ViolationAction::BlockAndLog => { + // Check for disallowed placeholders before forwarding or substituting data. + if let Some(action) = self.detect_blocking_action(data, &header_str) { + match action { + BlockingAction::Block => return Err(action.into_violation_action()), + BlockingAction::BlockAndLog => { tracing::warn!("secret violation: placeholder detected for disallowed host"); - return None; + return Err(action.into_violation_action()); } - ViolationAction::BlockAndTerminate => { + BlockingAction::BlockAndTerminate => { tracing::error!( "secret violation: placeholder detected for disallowed host — terminating" ); - return None; + return Err(action.into_violation_action()); } } } self.update_tail(data); - if self.eligible.is_empty() { + if self.eligible_for_substitution.is_empty() { // No substitution needed. Return borrowed slice (zero-copy). - return Some(Cow::Borrowed(data)); + return Ok(Cow::Borrowed(data)); } - for secret in &self.eligible { + for secret in &self.eligible_for_substitution { // Skip secrets that require TLS identity on non-intercepted connections. if secret.require_tls_identity && !self.tls_intercepted { continue; @@ -226,29 +270,24 @@ impl SecretsHandler { let mut output = header_str; output.push_str(&body_str); - Some(Cow::Owned(output.into_bytes())) + Ok(Cow::Owned(output.into_bytes())) } - /// Returns true if no secrets are configured. + /// Returns true if this connection needs no secret substitution or violation detection. pub fn is_empty(&self) -> bool { - self.all_placeholders.is_empty() - } - - /// Returns true if a violation should terminate the sandbox. - pub fn terminates_on_violation(&self) -> bool { - matches!(self.on_violation, ViolationAction::BlockAndTerminate) + self.eligible_for_substitution.is_empty() && self.ineligible_for_substitution.is_empty() } - /// Check if any placeholder appears in data for a host that isn't allowed. + /// Returns the strongest blocking action for any placeholder appearing in data + /// for a host that isn't allowed to receive either the real secret or the placeholder. + /// /// Scans the raw bytes (stitched with the previous call's tail for /// cross-write detection), plus URL- and JSON-decoded variants for /// encoded-placeholder bypass attempts, plus base64-decoded Basic auth /// credentials. - fn has_violation(&self, data: &[u8], headers: &str) -> bool { - // Fast path: if all placeholders have matching eligible entries, no - // violation is possible (every secret is allowed for this host). - if self.eligible.len() == self.all_placeholders.len() { - return false; + fn detect_blocking_action(&self, data: &[u8], headers: &str) -> Option { + if self.ineligible_for_substitution.is_empty() { + return None; } let scan_buf: Cow<[u8]> = if self.prev_tail.is_empty() { @@ -261,21 +300,19 @@ impl SecretsHandler { }; let scan = scan_buf.as_ref(); - for placeholder in &self.all_placeholders { - if self.eligible.iter().any(|s| s.placeholder == *placeholder) { - continue; - } - let needle = placeholder.as_bytes(); + let mut detected = None; + for secret in &self.ineligible_for_substitution { + let needle = secret.placeholder.as_bytes(); if contains_bytes(scan, needle) || url_decoded_contains(scan, needle) || json_escaped_contains(scan, needle) - || basic_auth_decoded_contains(headers, placeholder) + || basic_auth_decoded_contains(headers, &secret.placeholder) { - return true; + detected = Some(strictest_violation_action(detected, secret.action)); } } - false + detected } /// Update the sliding-window tail with the trailing bytes of `data`, so @@ -422,6 +459,23 @@ fn find_header_boundary(data: &[u8]) -> Option { .map(|pos| pos + 4) } +/// Returns the stricter of two blocking actions, where +/// `BlockAndTerminate` > `BlockAndLog` > `Block`. +fn strictest_violation_action( + current: Option, + candidate: BlockingAction, +) -> BlockingAction { + match (current, candidate) { + (Some(BlockingAction::BlockAndTerminate), _) | (_, BlockingAction::BlockAndTerminate) => { + BlockingAction::BlockAndTerminate + } + (Some(BlockingAction::BlockAndLog), _) | (_, BlockingAction::BlockAndLog) => { + BlockingAction::BlockAndLog + } + (Some(BlockingAction::Block), _) | (None, BlockingAction::Block) => BlockingAction::Block, + } +} + //-------------------------------------------------------------------------------------------------- // Tests //-------------------------------------------------------------------------------------------------- @@ -445,6 +499,7 @@ mod tests { placeholder: placeholder.into(), allowed_hosts: vec![HostPattern::Exact(host.into())], injection: SecretInjection::default(), + on_violation: None, require_tls_identity: true, } } @@ -477,7 +532,146 @@ mod tests { let mut handler = SecretsHandler::new(&config, "evil.com", true); let input = b"GET / HTTP/1.1\r\nAuthorization: Bearer $KEY\r\n\r\n"; - assert!(handler.substitute(input).is_none()); + assert_eq!( + handler.substitute(input).unwrap_err(), + ViolationAction::Block + ); + } + + #[test] + fn allowed_placeholder_substitutes_when_another_secret_is_ineligible() { + let allowed = make_secret("$ALLOWED", "allowed-secret", "api.openai.com"); + let blocked = make_secret("$BLOCKED", "blocked-secret", "api.github.com"); + let config = make_config(vec![allowed, blocked]); + let mut handler = SecretsHandler::new(&config, "api.openai.com", true); + + let input = b"GET / HTTP/1.1\r\nAuthorization: Bearer $ALLOWED\r\n\r\n"; + let output = handler.substitute(input).unwrap(); + + assert_eq!( + String::from_utf8(output.into_owned()).unwrap(), + "GET / HTTP/1.1\r\nAuthorization: Bearer allowed-secret\r\n\r\n" + ); + } + + #[test] + fn global_passthrough_host_forwards_placeholder_unchanged() { + let mut config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]); + config.on_violation = + ViolationAction::Passthrough(vec![HostPattern::Exact("api.anthropic.com".into())]); + let mut handler = SecretsHandler::new(&config, "api.anthropic.com", true); + + let input = b"GET / HTTP/1.1\r\nAuthorization: Bearer $KEY\r\n\r\n"; + let output = handler.substitute(input).unwrap(); + assert_eq!(&*output, input); + } + + #[test] + fn per_secret_passthrough_host_forwards_placeholder_unchanged() { + let mut secret = make_secret("$KEY", "real-secret", "api.openai.com"); + secret.on_violation = Some(ViolationAction::Passthrough(vec![HostPattern::Exact( + "api.anthropic.com".into(), + )])); + let config = make_config(vec![secret]); + let mut handler = SecretsHandler::new(&config, "api.anthropic.com", true); + + let input = b"GET / HTTP/1.1\r\nAuthorization: Bearer $KEY\r\n\r\n"; + let output = handler.substitute(input).unwrap(); + assert_eq!(&*output, input); + } + + #[test] + fn global_passthrough_action_forwards_disallowed_placeholder_unchanged() { + let mut config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]); + config.on_violation = ViolationAction::Passthrough(vec![HostPattern::Any]); + let mut handler = SecretsHandler::new(&config, "evil.com", true); + + let input = b"GET / HTTP/1.1\r\nAuthorization: Bearer $KEY\r\n\r\n"; + let output = handler.substitute(input).unwrap(); + assert_eq!(&*output, input); + } + + #[test] + fn passthrough_only_connection_has_no_handler_work() { + let mut config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]); + config.on_violation = ViolationAction::Passthrough(vec![HostPattern::Any]); + let handler = SecretsHandler::new(&config, "evil.com", true); + + assert!(handler.is_empty()); + } + + #[test] + fn passthrough_host_does_not_allow_other_disallowed_placeholders() { + let mut passthrough = make_secret("$PASSTHROUGH", "real-secret-a", "api.openai.com"); + passthrough.on_violation = Some(ViolationAction::Passthrough(vec![HostPattern::Exact( + "api.anthropic.com".into(), + )])); + let blocked = make_secret("$BLOCKED", "real-secret-b", "api.github.com"); + let config = make_config(vec![passthrough, blocked]); + let mut handler = SecretsHandler::new(&config, "api.anthropic.com", true); + + let input = b"GET / HTTP/1.1\r\nX-A: $PASSTHROUGH\r\nX-B: $BLOCKED\r\n\r\n"; + assert_eq!( + handler.substitute(input).unwrap_err(), + ViolationAction::Block + ); + } + + #[test] + fn per_secret_passthrough_blocks_for_non_matching_host() { + let mut secret = make_secret("$KEY", "real-secret", "api.openai.com"); + secret.on_violation = Some(ViolationAction::Passthrough(vec![HostPattern::Exact( + "api.anthropic.com".into(), + )])); + let config = make_config(vec![secret]); + let mut handler = SecretsHandler::new(&config, "evil.com", true); + + let input = b"GET / HTTP/1.1\r\nAuthorization: Bearer $KEY\r\n\r\n"; + assert_eq!( + handler.substitute(input).unwrap_err(), + ViolationAction::BlockAndLog + ); + } + + #[test] + fn global_passthrough_blocks_for_non_matching_host() { + let mut config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]); + config.on_violation = + ViolationAction::Passthrough(vec![HostPattern::Exact("api.anthropic.com".into())]); + let mut handler = SecretsHandler::new(&config, "evil.com", true); + + let input = b"GET / HTTP/1.1\r\nAuthorization: Bearer $KEY\r\n\r\n"; + assert_eq!( + handler.substitute(input).unwrap_err(), + ViolationAction::BlockAndLog + ); + } + + #[test] + fn global_block_and_terminate_marks_violation_as_terminating() { + let mut config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]); + config.on_violation = ViolationAction::BlockAndTerminate; + let mut handler = SecretsHandler::new(&config, "evil.com", true); + + let input = b"GET / HTTP/1.1\r\nAuthorization: Bearer $KEY\r\n\r\n"; + assert_eq!( + handler.substitute(input).unwrap_err(), + ViolationAction::BlockAndTerminate + ); + } + + #[test] + fn per_secret_block_and_terminate_marks_violation_as_terminating() { + let mut secret = make_secret("$KEY", "real-secret", "api.openai.com"); + secret.on_violation = Some(ViolationAction::BlockAndTerminate); + let config = make_config(vec![secret]); + let mut handler = SecretsHandler::new(&config, "evil.com", true); + + let input = b"GET / HTTP/1.1\r\nAuthorization: Bearer $KEY\r\n\r\n"; + assert_eq!( + handler.substitute(input).unwrap_err(), + ViolationAction::BlockAndTerminate + ); } #[test] @@ -634,7 +828,10 @@ mod tests { let encoded = BASE64.encode(b"user:$MSB_PASSWORD"); let input = format!("GET / HTTP/1.1\r\nAuthorization: Basic {encoded}\r\n\r\n"); - assert!(handler.substitute(input.as_bytes()).is_none()); + assert_eq!( + handler.substitute(input.as_bytes()).unwrap_err(), + ViolationAction::Block + ); } #[test] @@ -683,7 +880,10 @@ mod tests { // `%24KEY` is the URL-encoded form of `$KEY`. let input = b"GET /api?token=%24KEY HTTP/1.1\r\nHost: evil.com\r\n\r\n"; - assert!(handler.substitute(input).is_none()); + assert_eq!( + handler.substitute(input).unwrap_err(), + ViolationAction::Block + ); } #[test] @@ -692,7 +892,10 @@ mod tests { let mut handler = SecretsHandler::new(&config, "evil.com", true); let input = b"POST / HTTP/1.1\r\nContent-Length: 13\r\n\r\nkey=%24KEY&x=1"; - assert!(handler.substitute(input).is_none()); + assert_eq!( + handler.substitute(input).unwrap_err(), + ViolationAction::Block + ); } #[test] @@ -703,7 +906,10 @@ mod tests { // `$KEY` is the JSON unicode-escape form of `$KEY`. let input = b"POST / HTTP/1.1\r\nContent-Type: application/json\r\n\r\n{\"k\":\"\\u0024KEY\"}"; - assert!(handler.substitute(input).is_none()); + assert_eq!( + handler.substitute(input).unwrap_err(), + ViolationAction::Block + ); } #[test] @@ -716,9 +922,12 @@ mod tests { let second = b"EY\r\nHost: evil.com\r\n\r\n"; // The first chunk doesn't contain the full placeholder, so it forwards. - assert!(handler.substitute(first).is_some()); + assert!(handler.substitute(first).is_ok()); // The second chunk completes the placeholder when stitched with the tail. - assert!(handler.substitute(second).is_none()); + assert_eq!( + handler.substitute(second).unwrap_err(), + ViolationAction::Block + ); } #[test] diff --git a/crates/network/lib/tls/proxy.rs b/crates/network/lib/tls/proxy.rs index 17ec350b..3e20b98c 100644 --- a/crates/network/lib/tls/proxy.rs +++ b/crates/network/lib/tls/proxy.rs @@ -19,6 +19,7 @@ use tokio::sync::mpsc; use super::sni; use super::state::TlsState; use crate::policy::{EgressEvaluation, HostnameSource, NetworkPolicy, Protocol}; +use crate::secrets::config::ViolationAction; use crate::secrets::handler::SecretsHandler; use crate::shared::SharedState; @@ -358,20 +359,21 @@ async fn forward_plaintext( continue; } - let substituted = secrets_handler.substitute(&buf[..n]); - if let Some(data) = substituted { - server_tls.write_all(&data).await?; - continue; - } - - // Violation: placeholder going to disallowed host. Drop the connection. - if secrets_handler.terminates_on_violation() { - shared.trigger_termination(); + match secrets_handler.substitute(&buf[..n]) { + Ok(data) => { + server_tls.write_all(&data).await?; + } + Err(action) => { + // Violation: placeholder going to disallowed host. Drop the connection. + if matches!(action, ViolationAction::BlockAndTerminate) { + shared.trigger_termination(); + } + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "secret violation: placeholder sent to disallowed host", + )); + } } - return Err(io::Error::new( - io::ErrorKind::PermissionDenied, - "secret violation: placeholder sent to disallowed host", - )); } Ok(()) } diff --git a/docs/cli/sandbox-commands.mdx b/docs/cli/sandbox-commands.mdx index 0a6e6697..94c23ed4 100644 --- a/docs/cli/sandbox-commands.mdx +++ b/docs/cli/sandbox-commands.mdx @@ -75,7 +75,7 @@ msb run --name public-api -p 0.0.0.0:8000:8000 python | `--max-connections` | Limit the number of concurrent network connections | | `--trust-host-cas` | Ship the host's trusted root CAs into the guest so outbound TLS works behind corporate MITM proxies (Cloudflare Warp Zero Trust, Zscaler, etc.) whose gateway CA is installed on the host but unknown to the guest's stock bundle. Opt-in; by default the guest validates against its stock Mozilla bundle only | | `--secret` | Inject a secret that is only sent to an allowed host (`ENV=VALUE@HOST`) | -| `--on-secret-violation` | Action when a secret is sent to a disallowed host (`block`, `block-and-log`, `block-and-terminate`) | +| `--on-secret-violation` | Action when a secret is sent to a disallowed host (`block`, `block-and-log`, `block-and-terminate`, `passthrough`) | | `--tls-intercept` | Intercept and inspect HTTPS traffic via a built-in TLS proxy | | `--tls-intercept-port` | TCP port to apply TLS interception on (default: 443) | | `--tls-bypass` | Skip TLS interception for a domain (e.g. `*.internal.com`) | diff --git a/docs/sdk/go/secrets.mdx b/docs/sdk/go/secrets.mdx index dac2eb65..a3680743 100644 --- a/docs/sdk/go/secrets.mdx +++ b/docs/sdk/go/secrets.mdx @@ -21,6 +21,17 @@ sb, err := m.CreateSandbox(ctx, "agent", ) ``` +## Host Matching + +Go does not expose the Rust `HostPattern` enum directly. Instead, exact and wildcard host patterns are represented with fields on `SecretEntry` and `SecretEnvOptions`. + +| Runtime pattern | Go API | Matches | +|-----------------|--------|---------| +| `HostPattern::Exact("api.example.com")` | `AllowHosts: []string{"api.example.com"}` | That exact SNI host | +| `HostPattern::Wildcard("*.example.com")` | `AllowHostPatterns: []string{"*.example.com"}` | Hosts matching the wildcard pattern | + +These fields decide where the real secret value may be substituted. A host that is not matched by either field is disallowed for that secret, so sending its placeholder to that host triggers the configured [`ViolationAction`](#violationaction). + ## Secret Helpers that construct [`SecretEntry`](#secretentry) values. Access via the package-level `Secret` value. diff --git a/docs/sdk/python/networking.mdx b/docs/sdk/python/networking.mdx index cc44c2cf..1de81eeb 100644 --- a/docs/sdk/python/networking.mdx +++ b/docs/sdk/python/networking.mdx @@ -21,6 +21,7 @@ Network( ipv4_pool: str | None = None, ipv6_pool: str | None = None, max_connections: int | None = None, + on_secret_violation: ViolationAction | ViolationPolicy = ViolationAction.BLOCK_AND_LOG, trust_host_cas: bool | None = None, ) ``` @@ -36,6 +37,7 @@ Network( | ipv4_pool | `str \| None` | IPv4 pool used for per-sandbox `/30` guest subnets. Defaults to `172.16.0.0/12` | | ipv6_pool | `str \| None` | IPv6 pool used for per-sandbox `/64` guest prefixes. Defaults to `fd42:6d73:62::/48` | | max_connections | `int \| None` | Maximum concurrent connections | +| on_secret_violation | [`ViolationAction`](/sdk/python/secrets#violationaction) `\|` [`ViolationPolicy`](/sdk/python/secrets#violationpolicy) | Sandbox-wide action when a secret placeholder reaches a disallowed host | | trust_host_cas | `bool \| None` | Ship the host's trusted root CAs into the guest at boot so outbound TLS works behind corporate MITM proxies (Cloudflare Warp Zero Trust, Zscaler, Netskope, etc.). These proxies install a gateway CA on the host that's unknown to the guest's stock Mozilla bundle. Opt-in | --- diff --git a/docs/sdk/python/secrets.mdx b/docs/sdk/python/secrets.mdx index 94956c0c..b742e0ba 100644 --- a/docs/sdk/python/secrets.mdx +++ b/docs/sdk/python/secrets.mdx @@ -24,7 +24,7 @@ def env( allow_host_patterns: Sequence[str] = (), placeholder: str | None = None, require_tls: bool = True, - on_violation: ViolationAction = ViolationAction.BLOCK_AND_LOG, + on_violation: ViolationAction | ViolationPolicy = ViolationAction.BLOCK_AND_LOG, injection: SecretInjection | None = None, ) -> SecretEntry ``` @@ -41,7 +41,7 @@ Create a secret entry that maps an environment variable to a real value. The gue | allow_host_patterns | `Sequence[str]` | `()` | Wildcard host patterns (e.g. `"*.googleapis.com"`) | | placeholder | `str \| None` | `None` | Custom placeholder string. Auto-generated as `$MSB_` if not set. | | require_tls | `bool` | `True` | Only substitute on TLS-intercepted connections. Disable only if you know the traffic is safe. | -| on_violation | [`ViolationAction`](#violationaction) | `BLOCK_AND_LOG` | Action when the placeholder is sent to a disallowed host | +| on_violation | [`ViolationAction`](#violationaction) `\|` [`ViolationPolicy`](#violationpolicy) | `BLOCK_AND_LOG` | Per-secret violation behavior | | injection | [`SecretInjection`](#secretinjection) `\| None` | `None` | Where in the HTTP request to substitute. `None` uses the defaults. | **Returns** @@ -52,6 +52,20 @@ Create a secret entry that maps an environment variable to a real value. The gue --- +## Host Matching + +Python host settings map to the runtime `HostPattern` model. Allow-list fields decide where the real secret value may be substituted; passthrough fields decide where the placeholder may be forwarded unchanged. + +| Pattern kind | Python API | Matches | +|--------------|------------|---------| +| Exact host | `allow_hosts=("api.example.com",)`, `ViolationPolicy.passthrough(hosts=("api.example.com",))` | That exact SNI host | +| Wildcard host | `allow_host_patterns=("*.example.com",)`, `ViolationPolicy.passthrough(host_patterns=("*.example.com",))` | Hosts matching the wildcard pattern | +| Any host | `ViolationPolicy.passthrough(all_hosts=True)` | Every host for passthrough | + +Passthrough host patterns do **not** make a secret eligible for substitution. They only prevent blocking when the guest sends the placeholder to a matching host, so the request is forwarded with the placeholder unchanged. + +--- + ## Types ### SecretEntry @@ -66,7 +80,7 @@ Frozen dataclass returned by [`Secret.env()`](#secretenv) and used in `SandboxCo | allow_host_patterns | `tuple[str, ...]` | Wildcard patterns | | placeholder | `str \| None` | Placeholder string | | require_tls | `bool` | TLS requirement | -| on_violation | [`ViolationAction`](#violationaction) | Violation action | +| on_violation | [`ViolationAction`](#violationaction) `\|` [`ViolationPolicy`](#violationpolicy) | Per-secret violation behavior | | injection | [`SecretInjection`](#secretinjection) | Per-request injection scopes | ### SecretInjection @@ -89,3 +103,16 @@ String enum ([`StrEnum`](https://docs.python.org/3/library/enum.html#enum.StrEnu | `"block"` | Silently drop the request. The guest sees a connection reset. | | `"block-and-log"` | Drop the request and emit a warning log on the host side. This is the default. | | `"block-and-terminate"` | Drop the request, log an error, and shut down the entire sandbox. | +| `"passthrough"` | Forward matching hosts with the placeholder unchanged. Non-matching hosts use the default secret violation action. | + +### ViolationPolicy + +Frozen dataclass for passthrough host policies. Use `ViolationPolicy.passthrough(...)` when selected hosts should receive placeholders unchanged. + +```python +ViolationPolicy.passthrough( + hosts=("api.anthropic.com",), + host_patterns=("*.example.com",), + all_hosts=False, +) +``` diff --git a/docs/sdk/rust/networking.mdx b/docs/sdk/rust/networking.mdx index bce06de7..8c77de15 100644 --- a/docs/sdk/rust/networking.mdx +++ b/docs/sdk/rust/networking.mdx @@ -638,10 +638,40 @@ Set the IPv6 pool used to derive per-sandbox `/64` guest prefixes. Defaults to ` #### on_secret_violation() ```rust -fn on_secret_violation(self, action: ViolationAction) -> Self +fn on_secret_violation( + self, + f: impl FnOnce(ViolationActionBuilder) -> ViolationActionBuilder, +) -> Self ``` -Set the action taken when a secret placeholder is detected in traffic destined for a host not in the secret's allow list. See [`ViolationAction`](#violationaction). +Set the action taken when a secret placeholder is detected in traffic destined for a host not in the secret's allow list. The builder can configure a blocking action or passthrough hosts: + +```rust +.network(|n| n.on_secret_violation(|v| { + v.block_and_log() + .passthrough_host("api.anthropic.com") + .passthrough_host_pattern("*.example.com") +})) +``` + +Passthrough hosts receive the placeholder unchanged. They do **not** receive real secret values. + +--- + +## ViolationActionBuilder + +Builder for secret violation behavior. Used by [`NetworkBuilder::on_secret_violation()`](#on_secret_violation) and `SecretBuilder::on_violation(...)`. + +| Method | Description | +|--------|-------------| +| `block()` | Block the request silently | +| `block_and_log()` | Block the request and emit a warning log | +| `block_and_terminate()` | Block the request and terminate the sandbox | +| `passthrough_host(host)` | Allow placeholders to pass through unchanged to an exact host | +| `passthrough_host_pattern(pattern)` | Allow placeholders to pass through unchanged to matching wildcard hosts | +| `passthrough_all_hosts(true)` | Allow placeholders to pass through unchanged to any host | + +Passthrough host calls accumulate. When passthrough hosts are configured, non-matching hosts use the default secret violation action. --- @@ -935,3 +965,4 @@ Action taken when a secret placeholder is sent to a disallowed host. | `Block` | Silently drop the request. The guest sees a connection reset. This is the default. | | `BlockAndLog` | Drop the request and emit a warning log on the host side. | | `BlockAndTerminate` | Drop the request, log an error, and shut down the entire sandbox. | +| `Passthrough(Vec)` | Forward matching hosts with the placeholder unchanged. Non-matching hosts use the default secret violation action. | diff --git a/docs/sdk/rust/secrets.mdx b/docs/sdk/rust/secrets.mdx index ffa3f21d..71defedd 100644 --- a/docs/sdk/rust/secrets.mdx +++ b/docs/sdk/rust/secrets.mdx @@ -6,6 +6,18 @@ icon: "key" See [Secrets](/sandboxes/secrets) for how placeholder substitution works and usage examples. +## HostPattern + +`HostPattern` is used anywhere a secret policy needs to match the destination host. Allow-list patterns decide where the real secret value may be substituted; passthrough patterns decide where the placeholder may be forwarded unchanged. + +| Variant | Builder methods | Matches | +|---------|-----------------|---------| +| `HostPattern::Exact("api.example.com")` | `allow_host("api.example.com")`, `passthrough_host("api.example.com")` | That exact SNI host | +| `HostPattern::Wildcard("*.example.com")` | `allow_host_pattern("*.example.com")`, `passthrough_host_pattern("*.example.com")` | Hosts matching the wildcard pattern | +| `HostPattern::Any` | `allow_any_host_dangerous(true)`, `passthrough_all_hosts(true)` | Every host | + +Passthrough host patterns do **not** make a secret eligible for substitution. They only prevent blocking when the guest sends the placeholder to a matching host, so the request is forwarded with the placeholder unchanged. + ## SecretBuilder Builder for configuring a secret's placeholder, allowed hosts, and injection scopes. Obtained through `SandboxBuilder::secret(|s| s...)`. Each secret maps an environment variable to a real value that is only revealed when traffic goes to an allowed host via the TLS proxy. @@ -172,6 +184,33 @@ When `true`, the secret is only substituted on TLS-intercepted connections where --- +#### on_violation() + +```rust +fn on_violation( + self, + f: impl FnOnce(ViolationActionBuilder) -> ViolationActionBuilder, +) -> Self +``` + +Configure violation behavior for this secret. This can override the sandbox-wide secret violation policy and can allow selected hosts to receive the placeholder unchanged: + +```rust +.secret(|s| s + .env("API_KEY") + .value(api_key) + .allow_host("api.github.com") + .on_violation(|v| { + v.block_and_log() + .passthrough_host("api.anthropic.com") + }) +) +``` + +Passthrough hosts do **not** receive the real secret value; substitution still only happens for hosts configured with [`allow_host()`](#allow_host) or [`allow_host_pattern()`](#allow_host_pattern). + +--- + #### value() ```rust @@ -212,10 +251,11 @@ Convenience method on `SandboxBuilder`. Equivalent to `.secret(|s| s.env(env_var ### ViolationAction -Configured via [`NetworkBuilder::on_secret_violation()`](/sdk/rust/networking#on_secret_violation). Determines what happens when the guest sends a request containing a secret placeholder to a host that is **not** in the secret's allow list. +Configured globally via [`NetworkBuilder::on_secret_violation()`](/sdk/rust/networking#on_secret_violation) or per secret via [`SecretBuilder::on_violation()`](#on_violation). Determines what happens when the guest sends a request containing a secret placeholder to a host that is **not** in the secret's substitution allow list. | Value | Description | |-------|-------------| | `Block` | Silently drop the request. The guest sees a connection reset. This is the default. | | `BlockAndLog` | Drop the request and emit a warning log on the host side. | | `BlockAndTerminate` | Drop the request, log an error, and shut down the entire sandbox. | +| `Passthrough(Vec)` | Forward matching hosts with the placeholder unchanged. Non-matching hosts use the default secret violation action. | diff --git a/docs/sdk/typescript/networking.mdx b/docs/sdk/typescript/networking.mdx index efff7638..4b78c973 100644 --- a/docs/sdk/typescript/networking.mdx +++ b/docs/sdk/typescript/networking.mdx @@ -1024,10 +1024,36 @@ Set the IPv6 pool used to derive per-sandbox `/64` guest prefixes. Defaults to ` #### onSecretViolation() ```typescript -onSecretViolation(action: ViolationAction): this +onSecretViolation(configure: (v: ViolationActionBuilder) => ViolationActionBuilder): this ``` -Set the action taken when a secret reaches a disallowed host. See [`ViolationAction`](/sdk/typescript/secrets#violationaction). +Configure the action taken when a secret reaches a disallowed host: + +```typescript +.network((n) => + n.onSecretViolation((v) => + v.blockAndLog() + .passthroughHost("api.anthropic.com") + ) +) +``` + +Passthrough hosts receive placeholders unchanged. They do **not** receive real secret values. + +--- + +## ViolationActionBuilder + +| Method | Description | +|--------|-------------| +| `block()` | Block the request silently | +| `blockAndLog()` | Block the request and emit a warning log | +| `blockAndTerminate()` | Block the request and terminate the sandbox | +| `passthroughHost(host)` | Allow placeholders to pass through unchanged to an exact host | +| `passthroughHostPattern(pattern)` | Allow placeholders to pass through unchanged to matching wildcard hosts | +| `passthroughAllHosts(true)` | Allow placeholders to pass through unchanged to any host | + +Passthrough host calls accumulate. When passthrough hosts are configured, non-matching hosts use the default secret violation action. --- diff --git a/docs/sdk/typescript/secrets.mdx b/docs/sdk/typescript/secrets.mdx index 0b5bfbe6..ed6f49b5 100644 --- a/docs/sdk/typescript/secrets.mdx +++ b/docs/sdk/typescript/secrets.mdx @@ -37,6 +37,20 @@ The same `secret(...)` and `secretEnv(...)` methods are also available inside `S --- +## Host Matching + +TypeScript host settings map to the runtime `HostPattern` model. Allow-list methods decide where the real secret value may be substituted; passthrough methods decide where the placeholder may be forwarded unchanged. + +| Pattern kind | Builder methods | Matches | +|--------------|-----------------|---------| +| Exact host | `allowHost("api.example.com")`, `passthroughHost("api.example.com")` | That exact SNI host | +| Wildcard host | `allowHostPattern("*.example.com")`, `passthroughHostPattern("*.example.com")` | Hosts matching the wildcard pattern | +| Any host | `allowAnyHostDangerous(true)`, `passthroughAllHosts(true)` | Every host | + +Passthrough host patterns do **not** make a secret eligible for substitution. They only prevent blocking when the guest sends the placeholder to a matching host, so the request is forwarded with the placeholder unchanged. + +--- + ## SecretBuilder Fluent builder used inside `SandboxBuilder.secret(s => ...)` or `NetworkBuilder.secret(s => ...)`. Every setter returns the same builder so calls can be chained. @@ -49,6 +63,7 @@ Fluent builder used inside `SandboxBuilder.secret(s => ...)` or `NetworkBuilder. | `allowHost(host)` | Allow substitution to a specific host (exact match). | | `allowHostPattern(pattern)` | Allow substitution to any host matching a wildcard. | | `allowAnyHostDangerous(true)` | Permit substitution to any host. Acknowledge the risk explicitly. | +| `onViolation(configure)` | Configure per-secret violation behavior with a `ViolationActionBuilder`. | | `requireTlsIdentity(enabled)` | Require a verified TLS identity before substituting. Default `true`. | | `injectHeaders(enabled)` | Allow substitution in HTTP headers. Default `true`. | | `injectBasicAuth(enabled)` | Decode `Authorization: Basic ` credentials, substitute the placeholder in the decoded `user:password`, and re-encode. Default `true`. Other schemes are handled by `injectHeaders`. | @@ -116,7 +131,7 @@ Controls where the TLS proxy substitutes the placeholder with the real value. Ea Action taken when a secret placeholder is sent to a disallowed host. Set on the network builder via `NetworkBuilder.onSecretViolation(action)`. ```typescript -type ViolationAction = "block" | "block-and-log" | "block-and-terminate"; +type ViolationAction = "block" | "block-and-log" | "block-and-terminate" | "passthrough"; ``` | Value | Description | @@ -124,3 +139,4 @@ type ViolationAction = "block" | "block-and-log" | "block-and-terminate"; | `'block'` | Silently drop the request. The guest sees a connection reset. This is the default. | | `'block-and-log'` | Drop the request and emit a warning log on the host side. | | `'block-and-terminate'` | Drop the request, log an error, and shut down the entire sandbox. | +| `'passthrough'` | Forward matching hosts with the placeholder unchanged. Non-matching hosts use the default secret violation action. | diff --git a/mcp b/mcp index 22ac662f..13e8c7a0 160000 --- a/mcp +++ b/mcp @@ -1 +1 @@ -Subproject commit 22ac662f900022d6ef07bb78f00e4043d076f0a7 +Subproject commit 13e8c7a00615fc2a24df0979027476718a6e629a diff --git a/sdk/go/native/src/lib.rs b/sdk/go/native/src/lib.rs index d11b2c8d..cf405856 100644 --- a/sdk/go/native/src/lib.rs +++ b/sdk/go/native/src/lib.rs @@ -60,7 +60,7 @@ use microsandbox::{ snapshot::{ExportOpts, SnapshotDestination, SnapshotFormat}, volume::{Volume, VolumeBuilder, VolumeHandle}, }; -use microsandbox_network::secrets::config::ViolationAction; +use microsandbox_network::{builder::ViolationActionBuilder, secrets::config::ViolationAction}; use tokio::runtime::Runtime; use tokio_stream::StreamExt as _; use tokio_util::sync::CancellationToken; @@ -1121,7 +1121,9 @@ fn apply_network( // Sandbox-wide secret violation action. if let Some(ref violation) = net.on_secret_violation { let action = parse_violation_action(violation)?; - builder = builder.network(move |n| n.on_secret_violation(action)); + builder = builder.network(move |n| { + n.on_secret_violation(move |_| ViolationActionBuilder::from_action(action)) + }); } // Ports nested inside network object. @@ -1319,6 +1321,12 @@ fn apply_secret( let allow_host_patterns = s.allow_host_patterns.clone(); let placeholder = s.placeholder.clone(); let require_tls = s.require_tls; + let on_violation = s + .on_violation + .as_ref() + .map(|violation| parse_violation_action(violation)) + .transpose()?; + builder = builder.secret(move |mut sb| { sb = sb.env(&env_var).value(value.clone()); for h in &allow_hosts { @@ -1333,12 +1341,11 @@ fn apply_secret( if let Some(req) = require_tls { sb = sb.require_tls_identity(req); } + if let Some(action) = on_violation { + sb = sb.on_violation(move |_| ViolationActionBuilder::from_action(action)); + } sb }); - if let Some(ref violation) = s.on_violation { - let action = parse_violation_action(violation)?; - builder = builder.network(move |n| n.on_secret_violation(action)); - } Ok(builder) } diff --git a/sdk/node-ts/native/index.cjs b/sdk/node-ts/native/index.cjs index 22dd9695..8fe1e566 100644 --- a/sdk/node-ts/native/index.cjs +++ b/sdk/node-ts/native/index.cjs @@ -641,6 +641,8 @@ module.exports.SnapshotHandle = nativeBinding.SnapshotHandle module.exports.JsSnapshotHandle = nativeBinding.JsSnapshotHandle module.exports.TlsBuilder = nativeBinding.TlsBuilder module.exports.JsTlsBuilder = nativeBinding.JsTlsBuilder +module.exports.ViolationActionBuilder = nativeBinding.ViolationActionBuilder +module.exports.JsViolationActionBuilder = nativeBinding.JsViolationActionBuilder module.exports.Volume = nativeBinding.Volume module.exports.JsVolume = nativeBinding.JsVolume module.exports.VolumeBuilder = nativeBinding.VolumeBuilder diff --git a/sdk/node-ts/native/index.d.ts b/sdk/node-ts/native/index.d.ts index 63ba4437..dc9193da 100644 --- a/sdk/node-ts/native/index.d.ts +++ b/sdk/node-ts/native/index.d.ts @@ -442,11 +442,8 @@ export declare class NetworkBuilder { * interface. The closure receives a fresh `InterfaceOverridesBuilder`. */ interface(configure: (arg: InterfaceOverridesBuilder) => InterfaceOverridesBuilder): this - /** - * Set the violation action for secrets: `"block" | "block-and-log" - * | "block-and-terminate"`. - */ - onSecretViolation(action: string): this + /** Configure the violation action for secrets. */ + onSecretViolation(configure: (arg: JsViolationActionBuilder) => JsViolationActionBuilder): this /** Set the maximum number of concurrent connections. */ maxConnections(max: number): this /** Set the IPv4 pool used for per-sandbox /30 guest subnets. */ @@ -1137,6 +1134,8 @@ export declare class SecretBuilder { injectQuery(enabled: boolean): this /** Configure request body injection (default: false). */ injectBody(enabled: boolean): this + /** Configure violation behavior for this secret. */ + onViolation(configure: (arg: JsViolationActionBuilder) => JsViolationActionBuilder): this /** * Materialize into a `SecretEntry`. Panics if `env` or `value` weren't * set (matches the underlying Rust builder's contract; surface as a @@ -1253,6 +1252,24 @@ export declare class TlsBuilder { } export type JsTlsBuilder = TlsBuilder +/** Fluent builder for secret violation behavior. */ +export declare class ViolationActionBuilder { + constructor() + /** Block the request silently. */ + block(): this + /** Block the request and log a warning. */ + blockAndLog(): this + /** Block the request and terminate the sandbox. */ + blockAndTerminate(): this + /** Allow an exact host to receive placeholders unchanged. */ + passthroughHost(host: string): this + /** Allow hosts matching a wildcard pattern to receive placeholders unchanged. */ + passthroughHostPattern(pattern: string): this + /** Allow any host to receive placeholders unchanged. */ + passthroughAllHosts(iUnderstand: boolean): this +} +export type JsViolationActionBuilder = ViolationActionBuilder + export declare class Volume { static get(name: string): Promise static list(): Promise> diff --git a/sdk/node-ts/native/lib.rs b/sdk/node-ts/native/lib.rs index 38db8aae..d9df5f7a 100644 --- a/sdk/node-ts/native/lib.rs +++ b/sdk/node-ts/native/lib.rs @@ -33,5 +33,6 @@ mod snapshot; mod snapshot_builder; mod tls_builder; mod types; +mod violation_action_builder; mod volume; mod volume_builder; diff --git a/sdk/node-ts/native/network_builder.rs b/sdk/node-ts/native/network_builder.rs index 8461d010..6ac83489 100644 --- a/sdk/node-ts/native/network_builder.rs +++ b/sdk/node-ts/native/network_builder.rs @@ -6,13 +6,13 @@ use std::str::FromStr; use microsandbox_network::builder::NetworkBuilder as RustNetworkBuilder; use microsandbox_network::policy::NetworkPolicy as RustNetworkPolicy; -use microsandbox_network::secrets::config::ViolationAction as RustViolationAction; use crate::dns_builder::JsDnsBuilder; use crate::interface_overrides_builder::JsInterfaceOverridesBuilder; use crate::network_policy_builder::JsNetworkPolicyBuilder; use crate::secret_builder::JsSecretBuilder; use crate::tls_builder::JsTlsBuilder; +use crate::violation_action_builder::JsViolationActionBuilder; //-------------------------------------------------------------------------------------------------- // Types @@ -221,13 +221,21 @@ impl JsNetworkBuilder { Ok(self) } - /// Set the violation action for secrets: `"block" | "block-and-log" - /// | "block-and-terminate"`. + /// Configure the violation action for secrets. #[napi(js_name = "onSecretViolation")] - pub fn on_secret_violation(&mut self, action: String) -> Result<&Self> { - let act = parse_violation_action(&action)?; + pub fn on_secret_violation( + &mut self, + env: &Env, + configure: Function< + ClassInstance, + ClassInstance, + >, + ) -> Result<&Self> { + let initial = JsViolationActionBuilder::new().into_instance(env)?; + let mut returned = configure.call(initial)?; + let violation_builder = returned.take_inner_builder()?; let prev = self.take_inner(); - self.inner = Some(prev.on_secret_violation(act)); + self.inner = Some(prev.on_secret_violation(|_default| violation_builder)); Ok(self) } @@ -306,18 +314,3 @@ impl JsNetworkBuilder { .ok_or_else(|| napi::Error::from_reason("NetworkBuilder already consumed")) } } - -//-------------------------------------------------------------------------------------------------- -// Functions -//-------------------------------------------------------------------------------------------------- - -fn parse_violation_action(s: &str) -> Result { - match s { - "block" => Ok(RustViolationAction::Block), - "block-and-log" => Ok(RustViolationAction::BlockAndLog), - "block-and-terminate" => Ok(RustViolationAction::BlockAndTerminate), - other => Err(napi::Error::from_reason(format!( - "unknown violation action `{other}` (expected block | block-and-log | block-and-terminate)" - ))), - } -} diff --git a/sdk/node-ts/native/secret_builder.rs b/sdk/node-ts/native/secret_builder.rs index 9ccce676..0898b254 100644 --- a/sdk/node-ts/native/secret_builder.rs +++ b/sdk/node-ts/native/secret_builder.rs @@ -2,7 +2,9 @@ use napi::bindgen_prelude::*; use napi_derive::napi; use microsandbox_network::builder::SecretBuilder as RustSecretBuilder; -use microsandbox_network::secrets::config::{HostPattern, SecretEntry as RustSecretEntry}; +use microsandbox_network::secrets::config::SecretEntry as RustSecretEntry; + +use crate::violation_action_builder::JsViolationActionBuilder; //-------------------------------------------------------------------------------------------------- // Types @@ -148,6 +150,24 @@ impl JsSecretBuilder { self } + /// Configure violation behavior for this secret. + #[napi(js_name = "onViolation")] + pub fn on_violation( + &mut self, + env: &Env, + configure: Function< + ClassInstance, + ClassInstance, + >, + ) -> Result<&Self> { + let initial = JsViolationActionBuilder::new().into_instance(env)?; + let mut returned = configure.call(initial)?; + let violation_builder = returned.take_inner_builder()?; + let prev = self.take_inner(); + self.inner = Some(prev.on_violation(|_default| violation_builder)); + Ok(self) + } + /// Materialize into a `SecretEntry`. Panics if `env` or `value` weren't /// set (matches the underlying Rust builder's contract; surface as a /// typed error here). @@ -205,9 +225,11 @@ pub(crate) fn to_js_secret_entry(entry: RustSecretEntry) -> JsSecretEntry { let mut allow_any_host = false; for h in entry.allowed_hosts { match h { - HostPattern::Exact(s) => allowed_hosts.push(s), - HostPattern::Wildcard(s) => allowed_host_patterns.push(s), - HostPattern::Any => allow_any_host = true, + microsandbox_network::secrets::config::HostPattern::Exact(s) => allowed_hosts.push(s), + microsandbox_network::secrets::config::HostPattern::Wildcard(s) => { + allowed_host_patterns.push(s) + } + microsandbox_network::secrets::config::HostPattern::Any => allow_any_host = true, } } JsSecretEntry { diff --git a/sdk/node-ts/native/violation_action_builder.rs b/sdk/node-ts/native/violation_action_builder.rs new file mode 100644 index 00000000..daa654ef --- /dev/null +++ b/sdk/node-ts/native/violation_action_builder.rs @@ -0,0 +1,82 @@ +use napi::bindgen_prelude::*; +use napi_derive::napi; + +use microsandbox_network::builder::ViolationActionBuilder as RustViolationActionBuilder; + +/// Fluent builder for secret violation behavior. +#[napi(js_name = "ViolationActionBuilder")] +pub struct JsViolationActionBuilder { + inner: Option, +} + +#[napi] +impl JsViolationActionBuilder { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: Some(RustViolationActionBuilder::new()), + } + } + + /// Block the request silently. + #[napi] + pub fn block(&mut self) -> &Self { + let prev = self.take_inner(); + self.inner = Some(prev.block()); + self + } + + /// Block the request and log a warning. + #[napi(js_name = "blockAndLog")] + pub fn block_and_log(&mut self) -> &Self { + let prev = self.take_inner(); + self.inner = Some(prev.block_and_log()); + self + } + + /// Block the request and terminate the sandbox. + #[napi(js_name = "blockAndTerminate")] + pub fn block_and_terminate(&mut self) -> &Self { + let prev = self.take_inner(); + self.inner = Some(prev.block_and_terminate()); + self + } + + /// Allow an exact host to receive placeholders unchanged. + #[napi(js_name = "passthroughHost")] + pub fn passthrough_host(&mut self, host: String) -> &Self { + let prev = self.take_inner(); + self.inner = Some(prev.passthrough_host(host)); + self + } + + /// Allow hosts matching a wildcard pattern to receive placeholders unchanged. + #[napi(js_name = "passthroughHostPattern")] + pub fn passthrough_host_pattern(&mut self, pattern: String) -> &Self { + let prev = self.take_inner(); + self.inner = Some(prev.passthrough_host_pattern(pattern)); + self + } + + /// Allow any host to receive placeholders unchanged. + #[napi(js_name = "passthroughAllHosts")] + pub fn passthrough_all_hosts(&mut self, i_understand: bool) -> &Self { + let prev = self.take_inner(); + self.inner = Some(prev.passthrough_all_hosts(i_understand)); + self + } +} + +impl JsViolationActionBuilder { + fn take_inner(&mut self) -> RustViolationActionBuilder { + self.inner + .take() + .expect("ViolationActionBuilder used after consumption") + } + + pub(crate) fn take_inner_builder(&mut self) -> Result { + self.inner + .take() + .ok_or_else(|| napi::Error::from_reason("ViolationActionBuilder already consumed")) + } +} diff --git a/sdk/node-ts/src/internal/napi.ts b/sdk/node-ts/src/internal/napi.ts index e2721a7f..9f1ae487 100644 --- a/sdk/node-ts/src/internal/napi.ts +++ b/sdk/node-ts/src/internal/napi.ts @@ -34,6 +34,7 @@ export interface NativeBindings { readonly DnsBuilder: NapiBuilderCtor; readonly TlsBuilder: NapiBuilderCtor; readonly SecretBuilder: NapiBuilderCtor; + readonly ViolationActionBuilder: NapiBuilderCtor; readonly NetworkBuilder: NapiBuilderCtor; readonly NetworkPolicyBuilder: NapiBuilderCtor; readonly RuleBuilder: NapiBuilderCtor; @@ -648,6 +649,9 @@ export interface NapiSecretBuilder { injectBasicAuth(enabled: boolean): this; injectQuery(enabled: boolean): this; injectBody(enabled: boolean): this; + onViolation( + configure: (b: NapiViolationActionBuilder) => NapiViolationActionBuilder, + ): this; build(): NapiSecretEntry; } @@ -685,7 +689,9 @@ export interface NapiNetworkBuilder { interface( configure: (b: NapiInterfaceOverridesBuilder) => NapiInterfaceOverridesBuilder, ): this; - onSecretViolation(action: string): this; + onSecretViolation( + configure: (b: NapiViolationActionBuilder) => NapiViolationActionBuilder, + ): this; maxConnections(max: number): this; ipv4Pool(pool: string): this; ipv6Pool(pool: string): this; @@ -699,6 +705,15 @@ export interface NapiInterfaceOverridesBuilder { ipv6(address: string): this; } +export interface NapiViolationActionBuilder { + block(): this; + blockAndLog(): this; + blockAndTerminate(): this; + passthroughHost(host: string): this; + passthroughHostPattern(pattern: string): this; + passthroughAllHosts(iUnderstand: boolean): this; +} + export interface NapiPullProgressEvent { readonly kind: string; readonly reference?: string; diff --git a/sdk/node-ts/src/violation-action.ts b/sdk/node-ts/src/violation-action.ts index ecd78aba..409a864a 100644 --- a/sdk/node-ts/src/violation-action.ts +++ b/sdk/node-ts/src/violation-action.ts @@ -1,8 +1,13 @@ /** Action taken when a secret would be sent to a disallowed host. */ -export type ViolationAction = "block" | "block-and-log" | "block-and-terminate"; +export type ViolationAction = + | "block" + | "block-and-log" + | "block-and-terminate" + | "passthrough"; export const ViolationActions: readonly ViolationAction[] = [ "block", "block-and-log", "block-and-terminate", + "passthrough", ] as const; diff --git a/sdk/node-ts/tests/unit/builders.test.ts b/sdk/node-ts/tests/unit/builders.test.ts index 1c48e8ff..af5d488b 100644 --- a/sdk/node-ts/tests/unit/builders.test.ts +++ b/sdk/node-ts/tests/unit/builders.test.ts @@ -9,6 +9,7 @@ import { NetworkBuilder, PatchBuilder, Sandbox, + SecretBuilder, Stdin, } from "../../dist/index.js"; @@ -353,6 +354,48 @@ describe("NetworkBuilder.secretEnvSimple (3-arg shorthand)", () => { }); }); +describe("NetworkBuilder secret passthrough", () => { + it("builds global passthrough violation policy", () => { + const cfg = new NetworkBuilder() + .onSecretViolation((v) => + v + .blockAndTerminate() + .passthroughHost("api.anthropic.com") + .passthroughHostPattern("*.anthropic.com"), + ) + .build() as { + secrets: { + onViolation: { + passthrough: unknown[]; + }; + }; + }; + + expect(cfg.secrets.onViolation).toEqual({ + passthrough: [ + { exact: "api.anthropic.com" }, + { wildcard: "*.anthropic.com" }, + ], + }); + }); + + it("builds per-secret passthrough violation policy", () => { + const secret = new SecretBuilder() + .env("API_KEY") + .value("sk-abc") + .allowHost("api.github.com") + .onViolation((v) => + v + .blockAndLog() + .passthroughHost("api.anthropic.com") + .passthroughHostPattern("*.anthropic.com"), + ) + .build(); + + expect(secret.allowedHosts).toEqual(["api.github.com"]); + }); +}); + describe("NetworkBuilder ports", () => { it("keeps loopback default and supports explicit bind addresses", () => { const cfg = new NetworkBuilder() diff --git a/sdk/python/microsandbox/__init__.py b/sdk/python/microsandbox/__init__.py index 98dc30b5..e7f2fbbe 100644 --- a/sdk/python/microsandbox/__init__.py +++ b/sdk/python/microsandbox/__init__.py @@ -117,6 +117,7 @@ Stdin, TlsConfig, ViolationAction, + ViolationPolicy, ) # Pass the bundled msb path to Rust explicitly. `MSB_PATH` remains a user @@ -202,6 +203,7 @@ "SecretInjection", "TlsConfig", "ViolationAction", + "ViolationPolicy", # Images / rootfs "Image", "ImageSource", diff --git a/sdk/python/microsandbox/types.py b/sdk/python/microsandbox/types.py index 3cd0777e..7c95a5d0 100644 --- a/sdk/python/microsandbox/types.py +++ b/sdk/python/microsandbox/types.py @@ -68,6 +68,58 @@ class ViolationAction(enum.StrEnum): BLOCK = "block" BLOCK_AND_LOG = "block-and-log" BLOCK_AND_TERMINATE = "block-and-terminate" + PASSTHROUGH = "passthrough" + +@dataclass(frozen=True, slots=True) +class ViolationPolicy: + """Secret violation behavior, including optional passthrough hosts.""" + fallback: ViolationAction = ViolationAction.BLOCK_AND_LOG + passthrough_hosts: tuple[str, ...] = () + passthrough_host_patterns: tuple[str, ...] = () + passthrough_all_hosts: bool = False + + @classmethod + def block(cls) -> ViolationPolicy: + return cls(fallback=ViolationAction.BLOCK) + + @classmethod + def block_and_log(cls) -> ViolationPolicy: + return cls(fallback=ViolationAction.BLOCK_AND_LOG) + + @classmethod + def block_and_terminate(cls) -> ViolationPolicy: + return cls(fallback=ViolationAction.BLOCK_AND_TERMINATE) + + @classmethod + def passthrough( + cls, + *, + hosts: Sequence[str] = (), + host_patterns: Sequence[str] = (), + all_hosts: bool = False, + ) -> ViolationPolicy: + return cls( + passthrough_hosts=tuple(hosts), + passthrough_host_patterns=tuple(host_patterns), + passthrough_all_hosts=all_hosts, + ) + + def _to_dict(self) -> str | dict: + if ( + not self.passthrough_hosts + and not self.passthrough_host_patterns + and not self.passthrough_all_hosts + ): + return str(self.fallback) + + passthrough: dict = {} + if self.passthrough_hosts: + passthrough["hosts"] = list(self.passthrough_hosts) + if self.passthrough_host_patterns: + passthrough["host_patterns"] = list(self.passthrough_host_patterns) + if self.passthrough_all_hosts: + passthrough["all_hosts"] = True + return {"passthrough": passthrough} class MountKind(enum.StrEnum): BIND = "bind" @@ -508,7 +560,7 @@ class SecretEntry: allow_host_patterns: tuple[str, ...] = () placeholder: str | None = None require_tls: bool = True - on_violation: ViolationAction = ViolationAction.BLOCK_AND_LOG + on_violation: ViolationAction | ViolationPolicy = ViolationAction.BLOCK_AND_LOG injection: SecretInjection = field(default_factory=SecretInjection) def _to_dict(self) -> dict: @@ -521,8 +573,9 @@ def _to_dict(self) -> dict: d["placeholder"] = self.placeholder if not self.require_tls: d["require_tls"] = False - if self.on_violation != ViolationAction.BLOCK_AND_LOG: - d["on_violation"] = str(self.on_violation) + violation = violation_policy_to_dict(self.on_violation) + if violation != str(ViolationAction.BLOCK_AND_LOG): + d["on_violation"] = violation injection = self.injection._to_dict() if injection: d["injection"] = injection @@ -540,7 +593,7 @@ def env( allow_host_patterns: Sequence[str] = (), placeholder: str | None = None, require_tls: bool = True, - on_violation: ViolationAction = ViolationAction.BLOCK_AND_LOG, + on_violation: ViolationAction | ViolationPolicy = ViolationAction.BLOCK_AND_LOG, injection: SecretInjection | None = None, ) -> SecretEntry: return SecretEntry( @@ -727,6 +780,7 @@ class Network: """IPv6 pool used to derive per-sandbox /64 guest prefixes. Defaults to ``fd42:6d73:62::/48``.""" max_connections: int | None = None + on_secret_violation: ViolationAction | ViolationPolicy = ViolationAction.BLOCK_AND_LOG @classmethod def none(cls) -> Network: @@ -768,8 +822,17 @@ def _to_dict(self) -> dict: d["ipv6_pool"] = self.ipv6_pool if self.max_connections is not None: d["max_connections"] = self.max_connections + violation = violation_policy_to_dict(self.on_secret_violation) + if violation != str(ViolationAction.BLOCK_AND_LOG): + d["on_secret_violation"] = violation return d + +def violation_policy_to_dict(policy: ViolationAction | ViolationPolicy) -> str | dict: + if isinstance(policy, ViolationPolicy): + return policy._to_dict() + return str(policy) + #-------------------------------------------------------------------------------------------------- # Types: Registry Auth #-------------------------------------------------------------------------------------------------- diff --git a/sdk/python/src/helpers.rs b/sdk/python/src/helpers.rs index 032240b3..6e140a6c 100644 --- a/sdk/python/src/helpers.rs +++ b/sdk/python/src/helpers.rs @@ -285,9 +285,15 @@ pub fn sandbox_builder_from_args( } // Secret violation action (top-level kwarg). - if let Some(violation) = extract_opt::(kwargs, "on_secret_violation")? { - let action = parse_violation_action(&violation)?; - builder = builder.network(|n| n.on_secret_violation(action)); + if let Some(violation_obj) = kwargs.get_item("on_secret_violation")? + && !violation_obj.is_none() + { + let action = parse_violation_action_obj(&violation_obj)?; + builder = builder.network(|n| { + n.on_secret_violation(|_| { + microsandbox_network::builder::ViolationActionBuilder::from_action(action) + }) + }); } Ok(builder) @@ -827,9 +833,15 @@ fn apply_network( } // Secret violation action (sandbox-level, not per-secret). - if let Some(violation) = extract_opt::(net, "on_secret_violation")? { - let action = parse_violation_action(&violation)?; - builder = builder.network(|n| n.on_secret_violation(action)); + if let Some(violation_obj) = net.get_item("on_secret_violation")? + && !violation_obj.is_none() + { + let action = parse_violation_action_obj(&violation_obj)?; + builder = builder.network(|n| { + n.on_secret_violation(|_| { + microsandbox_network::builder::ViolationActionBuilder::from_action(action) + }) + }); } // TLS config. @@ -935,6 +947,13 @@ fn apply_secret( let allow_hosts: Vec = extract_opt(secret, "allow_hosts")?.unwrap_or_default(); let allow_host_patterns: Vec = extract_opt(secret, "allow_host_patterns")?.unwrap_or_default(); + let on_violation = if let Some(violation_obj) = secret.get_item("on_violation")? + && !violation_obj.is_none() + { + Some(parse_violation_action_obj(&violation_obj)?) + } else { + None + }; let placeholder: Option = extract_opt(secret, "placeholder")?; let require_tls: Option = extract_opt(secret, "require_tls")?; @@ -960,6 +979,11 @@ fn apply_secret( for pattern in &allow_host_patterns { s = s.allow_host_pattern(pattern); } + if let Some(action) = on_violation { + s = s.on_violation(|_| { + microsandbox_network::builder::ViolationActionBuilder::from_action(action) + }); + } if let Some(ref ph) = placeholder { s = s.placeholder(ph); } @@ -1011,17 +1035,71 @@ fn as_dict<'py>(obj: &Bound<'py, PyAny>) -> PyResult> { fn parse_violation_action( s: &str, ) -> PyResult { - use microsandbox_network::secrets::config::ViolationAction; + use microsandbox_network::secrets::config::{HostPattern, ViolationAction}; match s { "block" => Ok(ViolationAction::Block), "block-and-log" | "block_and_log" => Ok(ViolationAction::BlockAndLog), "block-and-terminate" | "block_and_terminate" => Ok(ViolationAction::BlockAndTerminate), + "passthrough" => Ok(ViolationAction::Passthrough(vec![HostPattern::Any])), _ => Err(pyo3::exceptions::PyValueError::new_err(format!( "unknown violation action: {s}" ))), } } +fn parse_violation_action_obj( + obj: &Bound<'_, PyAny>, +) -> PyResult { + if let Ok(s) = obj.extract::() { + return parse_violation_action(&s); + } + + let dict = as_dict(obj)?; + if let Some(passthrough_obj) = dict.get_item("passthrough")? + && !passthrough_obj.is_none() + { + return parse_passthrough_policy(&as_dict(&passthrough_obj)?); + } + + Err(pyo3::exceptions::PyValueError::new_err( + "expected violation action string or {'passthrough': {...}}", + )) +} + +fn parse_passthrough_policy( + dict: &Bound<'_, PyDict>, +) -> PyResult { + use microsandbox_network::secrets::config::{HostPattern, ViolationAction}; + + if let Some(fallback) = extract_opt::(dict, "fallback")? + && matches!( + parse_violation_action(&fallback)?, + ViolationAction::Passthrough(_) + ) + { + return Err(pyo3::exceptions::PyValueError::new_err( + "passthrough fallback must be a blocking action", + )); + } + + let hosts: Vec = extract_opt(dict, "hosts")?.unwrap_or_default(); + let host_patterns: Vec = extract_opt(dict, "host_patterns")?.unwrap_or_default(); + let all_hosts = extract_opt::(dict, "all_hosts")?.unwrap_or(false); + + let mut patterns = Vec::new(); + for host in hosts { + patterns.push(HostPattern::Exact(host)); + } + for pattern in host_patterns { + patterns.push(HostPattern::Wildcard(pattern)); + } + if all_hosts { + patterns.push(HostPattern::Any); + } + + Ok(ViolationAction::Passthrough(patterns)) +} + fn extract_opt<'py, T: FromPyObject<'py>>( dict: &Bound<'py, PyDict>, key: &str, diff --git a/sdk/python/tests/test_secret_passthrough.py b/sdk/python/tests/test_secret_passthrough.py new file mode 100644 index 00000000..6ba1855f --- /dev/null +++ b/sdk/python/tests/test_secret_passthrough.py @@ -0,0 +1,47 @@ +"""Unit tests for secret placeholder passthrough configuration.""" + +from __future__ import annotations + +from microsandbox import Network, Secret, ViolationAction, ViolationPolicy + + +def test_violation_action_includes_passthrough() -> None: + assert ViolationAction.PASSTHROUGH == "passthrough" + + +def test_secret_passthrough_hosts_serialize() -> None: + secret = Secret.env( + "API_KEY", + value="sk-abc", + allow_hosts=("api.github.com",), + on_violation=ViolationPolicy.passthrough( + hosts=("api.anthropic.com",), + host_patterns=("*.anthropic.com",), + ), + ) + + assert secret._to_dict() == { + "env_var": "API_KEY", + "value": "sk-abc", + "allow_hosts": ["api.github.com"], + "on_violation": { + "passthrough": { + "hosts": ["api.anthropic.com"], + "host_patterns": ["*.anthropic.com"], + } + }, + } + + +def test_network_secret_passthrough_hosts_serialize() -> None: + network = Network( + on_secret_violation=ViolationPolicy.passthrough(all_hosts=True), + ) + + assert network._to_dict() == { + "on_secret_violation": { + "passthrough": { + "all_hosts": True, + } + }, + } diff --git a/skills b/skills index c7c02dd0..c0a25d93 160000 --- a/skills +++ b/skills @@ -1 +1 @@ -Subproject commit c7c02dd0be559aa5a182e0e0e5a4378f07c196f3 +Subproject commit c0a25d935c9e717cf40126f1cdca80043cc9ee8a