From a3c561c76d1e2778a1aedc70a275910f54f3730d Mon Sep 17 00:00:00 2001 From: Wells Bunker Date: Wed, 20 May 2026 17:27:37 -0600 Subject: [PATCH 1/6] Adding passthrough network policy, and adding secret level override for allow passthrough hosts --- crates/cli/lib/commands/common.rs | 18 +++- crates/network/lib/builder.rs | 75 ++++++++++++++ crates/network/lib/secrets/config.rs | 15 ++- crates/network/lib/secrets/handler.rs | 105 ++++++++++++++++++-- docs/cli/sandbox-commands.mdx | 2 +- docs/sdk/python/networking.mdx | 6 ++ docs/sdk/python/secrets.mdx | 10 +- docs/sdk/rust/networking.mdx | 21 ++++ docs/sdk/rust/secrets.mdx | 35 ++++++- docs/sdk/typescript/networking.mdx | 20 ++++ docs/sdk/typescript/secrets.mdx | 7 +- mcp | 2 +- sdk/node-ts/native/index.d.ts | 14 ++- sdk/node-ts/native/network_builder.rs | 21 +++- sdk/node-ts/native/secret_builder.rs | 33 ++++++ sdk/node-ts/src/internal/napi.ts | 6 ++ sdk/node-ts/src/network-config.ts | 2 + sdk/node-ts/src/violation-action.ts | 7 +- sdk/node-ts/tests/unit/builders.test.ts | 29 ++++++ sdk/python/microsandbox/types.py | 25 ++++- sdk/python/src/helpers.rs | 28 ++++++ sdk/python/tests/test_secret_passthrough.py | 41 ++++++++ skills | 2 +- 23 files changed, 494 insertions(+), 30 deletions(-) create mode 100644 sdk/python/tests/test_secret_passthrough.py diff --git a/crates/cli/lib/commands/common.rs b/crates/cli/lib/commands/common.rs index 6b471eefe..8098b889c 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, @@ -967,8 +967,9 @@ fn parse_violation_action( 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)), 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 +1254,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 8bc5b9479..6a2903779 100644 --- a/crates/network/lib/builder.rs +++ b/crates/network/lib/builder.rs @@ -48,6 +48,7 @@ pub struct SecretBuilder { value: Option, placeholder: Option, allowed_hosts: Vec, + passthrough_hosts: Vec, injection: SecretInjection, require_tls_identity: bool, } @@ -178,6 +179,7 @@ impl NetworkBuilder { value: value.into(), placeholder: placeholder.into(), allowed_hosts: vec![HostPattern::Exact(allowed_host.into())], + passthrough_hosts: Vec::new(), injection: SecretInjection::default(), require_tls_identity: true, }); @@ -190,6 +192,24 @@ impl NetworkBuilder { self } + /// Allow a host to receive secret placeholders without substitution. + pub fn allow_secret_passthrough_host(mut self, host: impl Into) -> Self { + self.config + .secrets + .passthrough_hosts + .push(HostPattern::Exact(host.into())); + self + } + + /// Allow hosts matching a wildcard pattern to receive secret placeholders without substitution. + pub fn allow_secret_passthrough_host_pattern(mut self, pattern: impl Into) -> Self { + self.config + .secrets + .passthrough_hosts + .push(HostPattern::Wildcard(pattern.into())); + self + } + /// Set the maximum number of concurrent connections. pub fn max_connections(mut self, max: usize) -> Self { self.config.max_connections = Some(max); @@ -370,6 +390,7 @@ impl SecretBuilder { value: None, placeholder: None, allowed_hosts: Vec::new(), + passthrough_hosts: Vec::new(), injection: SecretInjection::default(), require_tls_identity: true, } @@ -416,6 +437,19 @@ impl SecretBuilder { self } + /// Allow a host to receive this secret's placeholder without substitution. + pub fn allow_passthrough_host(mut self, host: impl Into) -> Self { + self.passthrough_hosts.push(HostPattern::Exact(host.into())); + self + } + + /// Allow hosts matching a wildcard pattern to receive this secret's placeholder without substitution. + pub fn allow_passthrough_host_pattern(mut self, pattern: impl Into) -> Self { + self.passthrough_hosts + .push(HostPattern::Wildcard(pattern.into())); + self + } + /// Require verified TLS identity before substituting (default: true). pub fn require_tls_identity(mut self, enabled: bool) -> Self { self.require_tls_identity = enabled; @@ -462,6 +496,7 @@ impl SecretBuilder { value, placeholder, allowed_hosts: self.allowed_hosts, + passthrough_hosts: self.passthrough_hosts, injection: self.injection, require_tls_identity: self.require_tls_identity, } @@ -524,4 +559,44 @@ 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_hosts() { + let cfg = NetworkBuilder::new() + .allow_secret_passthrough_host("api.anthropic.com") + .allow_secret_passthrough_host_pattern("*.anthropic.com") + .build() + .unwrap(); + + assert_eq!(cfg.secrets.passthrough_hosts.len(), 2); + assert!(matches!( + &cfg.secrets.passthrough_hosts[0], + HostPattern::Exact(host) if host == "api.anthropic.com" + )); + assert!(matches!( + &cfg.secrets.passthrough_hosts[1], + HostPattern::Wildcard(pattern) if pattern == "*.anthropic.com" + )); + } + + #[test] + fn secret_builder_sets_passthrough_hosts() { + let secret = SecretBuilder::new() + .env("TOKEN") + .value("secret-value") + .allow_host("api.github.com") + .allow_passthrough_host("api.anthropic.com") + .allow_passthrough_host_pattern("*.anthropic.com") + .build(); + + assert_eq!(secret.passthrough_hosts.len(), 2); + assert!(matches!( + &secret.passthrough_hosts[0], + HostPattern::Exact(host) if host == "api.anthropic.com" + )); + assert!(matches!( + &secret.passthrough_hosts[1], + HostPattern::Wildcard(pattern) if pattern == "*.anthropic.com" + )); + } } diff --git a/crates/network/lib/secrets/config.rs b/crates/network/lib/secrets/config.rs index 956e2b92e..81de316bb 100644 --- a/crates/network/lib/secrets/config.rs +++ b/crates/network/lib/secrets/config.rs @@ -13,6 +13,10 @@ pub struct SecretsConfig { #[serde(default)] pub secrets: Vec, + /// Hosts allowed to receive secret placeholders without substitution. + #[serde(default)] + pub passthrough_hosts: Vec, + /// Action on secret violation (placeholder leaked to disallowed host). #[serde(default)] pub on_violation: ViolationAction, @@ -34,6 +38,10 @@ pub struct SecretEntry { #[serde(default)] pub allowed_hosts: Vec, + /// Hosts allowed to receive this secret's placeholder without substitution. + #[serde(default)] + pub passthrough_hosts: Vec, + /// Where the secret can be injected. #[serde(default)] pub injection: SecretInjection, @@ -77,7 +85,7 @@ pub struct SecretInjection { } /// Action when a secret placeholder is detected going to a disallowed host. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum ViolationAction { /// Block the request silently. Block, @@ -86,6 +94,9 @@ pub enum ViolationAction { BlockAndLog, /// Block and terminate the sandbox. BlockAndTerminate, + /// Forward the request with the placeholder unchanged. + #[serde(rename = "passthrough")] + Passthrough, } //-------------------------------------------------------------------------------------------------- @@ -99,6 +110,7 @@ impl std::fmt::Debug for SecretEntry { .field("value", &"[REDACTED]") .field("placeholder", &self.placeholder) .field("allowed_hosts", &self.allowed_hosts) + .field("passthrough_hosts", &self.passthrough_hosts) .field("injection", &self.injection) .field("require_tls_identity", &self.require_tls_identity) .finish() @@ -198,6 +210,7 @@ mod tests { value: "v".into(), placeholder: "$K".into(), allowed_hosts: vec![], + passthrough_hosts: vec![], injection: SecretInjection::default(), require_tls_identity: true, }; diff --git a/crates/network/lib/secrets/handler.rs b/crates/network/lib/secrets/handler.rs index aebf74b41..eb988ebd0 100644 --- a/crates/network/lib/secrets/handler.rs +++ b/crates/network/lib/secrets/handler.rs @@ -21,11 +21,13 @@ use super::config::{SecretsConfig, ViolationAction}; pub struct SecretsHandler { /// Secrets eligible for substitution on this connection. eligible: Vec, + /// Secret placeholders allowed to pass through unchanged on this connection. + eligible_passthrough: 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). + /// Whether any disallowed placeholders exist (pre-computed for fast-path skip). has_ineligible: bool, /// Whether this connection is TLS-intercepted (not bypass). tls_intercepted: bool, @@ -122,7 +124,9 @@ impl SecretsHandler { /// (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 eligible_passthrough = Vec::new(); let mut all_placeholders = Vec::new(); + let global_passthrough_allowed = config.passthrough_hosts.iter().any(|p| p.matches(sni)); for secret in &config.secrets { all_placeholders.push(secret.placeholder.clone()); @@ -140,16 +144,23 @@ impl SecretsHandler { inject_body: secret.injection.body, require_tls_identity: secret.require_tls_identity, }); + } else { + let secret_passthrough_allowed = + secret.passthrough_hosts.iter().any(|p| p.matches(sni)); + if global_passthrough_allowed || secret_passthrough_allowed { + eligible_passthrough.push(secret.placeholder.clone()); + } } } - let has_ineligible = eligible.len() < all_placeholders.len(); + let has_ineligible = eligible.len() + eligible_passthrough.len() < all_placeholders.len(); let max_placeholder_len = all_placeholders.iter().map(String::len).max().unwrap_or(0); Self { eligible, + eligible_passthrough, all_placeholders, - on_violation: config.on_violation.clone(), + on_violation: config.on_violation, has_ineligible, tls_intercepted, max_placeholder_len, @@ -184,19 +195,24 @@ impl SecretsHandler { // 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::Block => { + self.update_tail(data); + return None; + } ViolationAction::BlockAndLog => { + self.update_tail(data); tracing::warn!("secret violation: placeholder detected for disallowed host"); return None; } ViolationAction::BlockAndTerminate => { + self.update_tail(data); tracing::error!( "secret violation: placeholder detected for disallowed host — terminating" ); return None; } + ViolationAction::Passthrough => {} } } self.update_tail(data); @@ -239,15 +255,16 @@ impl SecretsHandler { matches!(self.on_violation, ViolationAction::BlockAndTerminate) } - /// Check if any placeholder appears in data for a host that isn't allowed. + /// Check if any placeholder appears 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() { + // Fast path: if every placeholder is either substitutable or explicitly + // allowed to pass through on this host, no violation is possible. + if !self.has_ineligible { return false; } @@ -262,7 +279,9 @@ impl SecretsHandler { let scan = scan_buf.as_ref(); for placeholder in &self.all_placeholders { - if self.eligible.iter().any(|s| s.placeholder == *placeholder) { + if self.eligible.iter().any(|s| s.placeholder == *placeholder) + || self.eligible_passthrough.iter().any(|p| p == placeholder) + { continue; } let needle = placeholder.as_bytes(); @@ -434,6 +453,7 @@ mod tests { fn make_config(secrets: Vec) -> SecretsConfig { SecretsConfig { secrets, + passthrough_hosts: vec![], on_violation: ViolationAction::Block, } } @@ -444,6 +464,7 @@ mod tests { value: value.into(), placeholder: placeholder.into(), allowed_hosts: vec![HostPattern::Exact(host.into())], + passthrough_hosts: vec![], injection: SecretInjection::default(), require_tls_identity: true, } @@ -480,6 +501,70 @@ mod tests { assert!(handler.substitute(input).is_none()); } + #[test] + fn global_passthrough_host_forwards_placeholder_unchanged() { + let mut config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]); + config + .passthrough_hosts + .push(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); + assert!(!handler.terminates_on_violation()); + } + + #[test] + fn per_secret_passthrough_host_forwards_placeholder_unchanged() { + let mut secret = make_secret("$KEY", "real-secret", "api.openai.com"); + secret + .passthrough_hosts + .push(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; + 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_host_does_not_allow_other_disallowed_placeholders() { + let mut passthrough = make_secret("$PASSTHROUGH", "real-secret-a", "api.openai.com"); + passthrough + .passthrough_hosts + .push(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!(handler.substitute(input).is_none()); + } + + #[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!(handler.substitute(input).is_none()); + assert!(handler.terminates_on_violation()); + } + #[test] fn body_injection_disabled_by_default() { let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]); diff --git a/docs/cli/sandbox-commands.mdx b/docs/cli/sandbox-commands.mdx index 0a6e66976..94c23ed4f 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/python/networking.mdx b/docs/sdk/python/networking.mdx index cc44c2cf3..4e9e59ff8 100644 --- a/docs/sdk/python/networking.mdx +++ b/docs/sdk/python/networking.mdx @@ -21,6 +21,9 @@ Network( ipv4_pool: str | None = None, ipv6_pool: str | None = None, max_connections: int | None = None, + on_secret_violation: ViolationAction = ViolationAction.BLOCK_AND_LOG, + secret_passthrough_hosts: tuple[str, ...] = (), + secret_passthrough_host_patterns: tuple[str, ...] = (), trust_host_cas: bool | None = None, ) ``` @@ -36,6 +39,9 @@ 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) | Sandbox-wide action when a secret placeholder reaches a disallowed host | +| secret_passthrough_hosts | `tuple[str, ...]` | Hosts allowed to receive secret placeholders unchanged. Does not enable substitution | +| secret_passthrough_host_patterns | `tuple[str, ...]` | Wildcard host patterns allowed to receive secret placeholders unchanged. Does not enable substitution | | 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 94956c0c8..3fcc97bcb 100644 --- a/docs/sdk/python/secrets.mdx +++ b/docs/sdk/python/secrets.mdx @@ -22,9 +22,10 @@ def env( value: str, allow_hosts: Sequence[str] = (), allow_host_patterns: Sequence[str] = (), + passthrough_hosts: Sequence[str] = (), + passthrough_host_patterns: Sequence[str] = (), placeholder: str | None = None, require_tls: bool = True, - on_violation: ViolationAction = ViolationAction.BLOCK_AND_LOG, injection: SecretInjection | None = None, ) -> SecretEntry ``` @@ -39,9 +40,10 @@ Create a secret entry that maps an environment variable to a real value. The gue | value | `str` | - | The real secret value. Never enters the guest VM. **Required.** | | allow_hosts | `Sequence[str]` | `()` | Hosts allowed to receive the real value (exact match). The TLS proxy matches against the SNI. | | allow_host_patterns | `Sequence[str]` | `()` | Wildcard host patterns (e.g. `"*.googleapis.com"`) | +| passthrough_hosts | `Sequence[str]` | `()` | Hosts allowed to receive the placeholder unchanged. Does not enable substitution. | +| passthrough_host_patterns | `Sequence[str]` | `()` | Wildcard host patterns allowed to receive the placeholder unchanged. Does not enable substitution. | | 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 | | injection | [`SecretInjection`](#secretinjection) `\| None` | `None` | Where in the HTTP request to substitute. `None` uses the defaults. | **Returns** @@ -64,9 +66,10 @@ Frozen dataclass returned by [`Secret.env()`](#secretenv) and used in `SandboxCo | value | `str` | Secret value | | allow_hosts | `tuple[str, ...]` | Allowed hosts (exact match) | | allow_host_patterns | `tuple[str, ...]` | Wildcard patterns | +| passthrough_hosts | `tuple[str, ...]` | Hosts allowed to receive the placeholder unchanged | +| passthrough_host_patterns | `tuple[str, ...]` | Wildcard patterns allowed to receive the placeholder unchanged | | placeholder | `str \| None` | Placeholder string | | require_tls | `bool` | TLS requirement | -| on_violation | [`ViolationAction`](#violationaction) | Violation action | | injection | [`SecretInjection`](#secretinjection) | Per-request injection scopes | ### SecretInjection @@ -89,3 +92,4 @@ 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 the request with the placeholder unchanged. The real secret is not substituted. | diff --git a/docs/sdk/rust/networking.mdx b/docs/sdk/rust/networking.mdx index bce06de70..d909549a4 100644 --- a/docs/sdk/rust/networking.mdx +++ b/docs/sdk/rust/networking.mdx @@ -645,6 +645,26 @@ Set the action taken when a secret placeholder is detected in traffic destined f --- +#### allow_secret_passthrough_host() + +```rust +fn allow_secret_passthrough_host(self, host: impl Into) -> Self +``` + +Allow any configured secret placeholder to pass through unchanged to an exact host. This does **not** allow the host to receive the real secret value; substitution still only happens for hosts configured with `SecretBuilder::allow_host()`. + +--- + +#### allow_secret_passthrough_host_pattern() + +```rust +fn allow_secret_passthrough_host_pattern(self, pattern: impl Into) -> Self +``` + +Allow any configured secret placeholder to pass through unchanged to hosts matching a wildcard pattern (for example, `"*.anthropic.com"`). This does **not** allow those hosts to receive real secret values. + +--- + #### policy() ```rust @@ -935,3 +955,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` | Forward the request with the placeholder unchanged. The real secret is not substituted. | diff --git a/docs/sdk/rust/secrets.mdx b/docs/sdk/rust/secrets.mdx index ffa3f21d5..25289f04a 100644 --- a/docs/sdk/rust/secrets.mdx +++ b/docs/sdk/rust/secrets.mdx @@ -60,6 +60,38 @@ Add a wildcard host pattern. The `*` matches any subdomain prefix. --- +#### allow_passthrough_host() + +```rust +fn allow_passthrough_host(self, host: impl Into) -> Self +``` + +Allow this secret's placeholder to pass through unchanged to an exact host. This is useful when a downstream service may receive sandbox transcripts or request bodies containing placeholders, but must not receive the real secret value. It does **not** enable substitution for that host. + +**Parameters** + +| Name | Type | Description | +|------|------|-------------| +| host | `impl Into` | Exact hostname (e.g. `"api.anthropic.com"`) | + +--- + +#### allow_passthrough_host_pattern() + +```rust +fn allow_passthrough_host_pattern(self, pattern: impl Into) -> Self +``` + +Allow this secret's placeholder to pass through unchanged to hosts matching a wildcard pattern. It does **not** enable substitution for those hosts. + +**Parameters** + +| Name | Type | Description | +|------|------|-------------| +| pattern | `impl Into` | Wildcard pattern (e.g. `"*.anthropic.com"`) | + +--- + #### env() ```rust @@ -212,10 +244,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). 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 and is not configured as a passthrough host. | 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` | Forward the request with the placeholder unchanged. The real secret is not substituted. | diff --git a/docs/sdk/typescript/networking.mdx b/docs/sdk/typescript/networking.mdx index efff76380..155cf9336 100644 --- a/docs/sdk/typescript/networking.mdx +++ b/docs/sdk/typescript/networking.mdx @@ -1031,6 +1031,26 @@ Set the action taken when a secret reaches a disallowed host. See [`ViolationAct --- +#### allowSecretPassthroughHost() + +```typescript +allowSecretPassthroughHost(host: string): this +``` + +Allow configured secret placeholders to pass through unchanged to an exact host. This does **not** allow that host to receive real secret values. + +--- + +#### allowSecretPassthroughHostPattern() + +```typescript +allowSecretPassthroughHostPattern(pattern: string): this +``` + +Allow configured secret placeholders to pass through unchanged to hosts matching a wildcard pattern. This does **not** allow those hosts to receive real secret values. + +--- + #### policy() ```typescript diff --git a/docs/sdk/typescript/secrets.mdx b/docs/sdk/typescript/secrets.mdx index 0b5bfbe63..53f800295 100644 --- a/docs/sdk/typescript/secrets.mdx +++ b/docs/sdk/typescript/secrets.mdx @@ -49,6 +49,8 @@ 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. | +| `allowPassthroughHost(host)` | Allow the placeholder to pass through unchanged to a specific host. Does not enable substitution. | +| `allowPassthroughHostPattern(pattern)` | Allow the placeholder to pass through unchanged to hosts matching a wildcard. Does not enable substitution. | | `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`. | @@ -96,6 +98,8 @@ The object produced by `SecretBuilder.build()`. You normally construct one with | placeholder | `string \| null` | Custom placeholder; defaults to `$MSB_` when `null` | | allowedHosts | `readonly string[]` | Allowed hosts (exact match) | | allowedHostPatterns | `readonly string[]` | Wildcard host patterns | +| passthroughHosts | `readonly string[]` | Hosts allowed to receive the placeholder unchanged | +| passthroughHostPatterns | `readonly string[]` | Wildcard host patterns allowed to receive the placeholder unchanged | | allowAnyHost | `boolean` | Permit substitution to any host | | requireTlsIdentity | `boolean` | Require verified TLS identity | | injection | [`SecretInjection`](#secretinjection) | Where to substitute | @@ -116,7 +120,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 +128,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 the request with the placeholder unchanged. The real secret is not substituted. | diff --git a/mcp b/mcp index 22ac662f9..13e8c7a00 160000 --- a/mcp +++ b/mcp @@ -1 +1 @@ -Subproject commit 22ac662f900022d6ef07bb78f00e4043d076f0a7 +Subproject commit 13e8c7a00615fc2a24df0979027476718a6e629a diff --git a/sdk/node-ts/native/index.d.ts b/sdk/node-ts/native/index.d.ts index 3eb4bca8b..06e4401a2 100644 --- a/sdk/node-ts/native/index.d.ts +++ b/sdk/node-ts/native/index.d.ts @@ -417,9 +417,13 @@ export declare class NetworkBuilder { interface(configure: (arg: InterfaceOverridesBuilder) => InterfaceOverridesBuilder): this /** * Set the violation action for secrets: `"block" | "block-and-log" - * | "block-and-terminate"`. + * | "block-and-terminate" | "passthrough"`. */ onSecretViolation(action: string): this + /** Allow a host to receive secret placeholders without substitution. */ + allowSecretPassthroughHost(host: string): this + /** Allow hosts matching a wildcard pattern to receive secret placeholders without substitution. */ + allowSecretPassthroughHostPattern(pattern: string): this /** Set the maximum number of concurrent connections. */ maxConnections(max: number): this /** Set the IPv4 pool used for per-sandbox /30 guest subnets. */ @@ -1083,6 +1087,10 @@ export declare class SecretBuilder { * Pass `true` to opt in. */ allowAnyHostDangerous(iUnderstand: boolean): this + /** Add an exact-match host that may receive the placeholder unchanged. */ + allowPassthroughHost(host: string): this + /** Add a wildcard host pattern that may receive the placeholder unchanged. */ + allowPassthroughHostPattern(pattern: string): this /** Require verified TLS identity before substituting (default: true). */ requireTlsIdentity(enabled: boolean): this /** Configure header injection (default: true). */ @@ -1676,6 +1684,10 @@ export interface SecretEntry { allowedHosts: Array /** Wildcard host patterns (e.g. `*.openai.com`) allowed to receive this secret. */ allowedHostPatterns: Array + /** Exact host names allowed to receive this secret's placeholder unchanged. */ + passthroughHosts: Array + /** Wildcard host patterns allowed to receive this secret's placeholder unchanged. */ + passthroughHostPatterns: Array /** Allow any host. **Dangerous** — secret can be exfiltrated. */ allowAnyHost: boolean /** Require verified TLS identity before substituting (default: true). */ diff --git a/sdk/node-ts/native/network_builder.rs b/sdk/node-ts/native/network_builder.rs index 8461d010a..591afef00 100644 --- a/sdk/node-ts/native/network_builder.rs +++ b/sdk/node-ts/native/network_builder.rs @@ -222,7 +222,7 @@ impl JsNetworkBuilder { } /// Set the violation action for secrets: `"block" | "block-and-log" - /// | "block-and-terminate"`. + /// | "block-and-terminate" | "passthrough"`. #[napi(js_name = "onSecretViolation")] pub fn on_secret_violation(&mut self, action: String) -> Result<&Self> { let act = parse_violation_action(&action)?; @@ -231,6 +231,22 @@ impl JsNetworkBuilder { Ok(self) } + /// Allow a host to receive secret placeholders without substitution. + #[napi(js_name = "allowSecretPassthroughHost")] + pub fn allow_secret_passthrough_host(&mut self, host: String) -> &Self { + let prev = self.take_inner(); + self.inner = Some(prev.allow_secret_passthrough_host(host)); + self + } + + /// Allow hosts matching a wildcard pattern to receive secret placeholders without substitution. + #[napi(js_name = "allowSecretPassthroughHostPattern")] + pub fn allow_secret_passthrough_host_pattern(&mut self, pattern: String) -> &Self { + let prev = self.take_inner(); + self.inner = Some(prev.allow_secret_passthrough_host_pattern(pattern)); + self + } + /// Set the maximum number of concurrent connections. #[napi(js_name = "maxConnections")] pub fn max_connections(&mut self, max: u32) -> &Self { @@ -316,8 +332,9 @@ fn parse_violation_action(s: &str) -> Result { "block" => Ok(RustViolationAction::Block), "block-and-log" => Ok(RustViolationAction::BlockAndLog), "block-and-terminate" => Ok(RustViolationAction::BlockAndTerminate), + "passthrough" => Ok(RustViolationAction::Passthrough), other => Err(napi::Error::from_reason(format!( - "unknown violation action `{other}` (expected block | block-and-log | block-and-terminate)" + "unknown violation action `{other}` (expected block | block-and-log | block-and-terminate | passthrough)" ))), } } diff --git a/sdk/node-ts/native/secret_builder.rs b/sdk/node-ts/native/secret_builder.rs index 9ccce6760..0e82d8a8d 100644 --- a/sdk/node-ts/native/secret_builder.rs +++ b/sdk/node-ts/native/secret_builder.rs @@ -22,6 +22,10 @@ pub struct JsSecretEntry { pub allowed_hosts: Vec, /// Wildcard host patterns (e.g. `*.openai.com`) allowed to receive this secret. pub allowed_host_patterns: Vec, + /// Exact host names allowed to receive this secret's placeholder unchanged. + pub passthrough_hosts: Vec, + /// Wildcard host patterns allowed to receive this secret's placeholder unchanged. + pub passthrough_host_patterns: Vec, /// Allow any host. **Dangerous** — secret can be exfiltrated. pub allow_any_host: bool, /// Require verified TLS identity before substituting (default: true). @@ -108,6 +112,22 @@ impl JsSecretBuilder { self } + /// Add an exact-match host that may receive the placeholder unchanged. + #[napi(js_name = "allowPassthroughHost")] + pub fn allow_passthrough_host(&mut self, host: String) -> &Self { + let prev = self.take_inner(); + self.inner = Some(prev.allow_passthrough_host(host)); + self + } + + /// Add a wildcard host pattern that may receive the placeholder unchanged. + #[napi(js_name = "allowPassthroughHostPattern")] + pub fn allow_passthrough_host_pattern(&mut self, pattern: String) -> &Self { + let prev = self.take_inner(); + self.inner = Some(prev.allow_passthrough_host_pattern(pattern)); + self + } + /// Require verified TLS identity before substituting (default: true). #[napi(js_name = "requireTlsIdentity")] pub fn require_tls_identity(&mut self, enabled: bool) -> &Self { @@ -203,6 +223,8 @@ pub(crate) fn to_js_secret_entry(entry: RustSecretEntry) -> JsSecretEntry { let mut allowed_hosts = Vec::new(); let mut allowed_host_patterns = Vec::new(); let mut allow_any_host = false; + let mut passthrough_hosts = Vec::new(); + let mut passthrough_host_patterns = Vec::new(); for h in entry.allowed_hosts { match h { HostPattern::Exact(s) => allowed_hosts.push(s), @@ -210,12 +232,23 @@ pub(crate) fn to_js_secret_entry(entry: RustSecretEntry) -> JsSecretEntry { HostPattern::Any => allow_any_host = true, } } + for h in entry.passthrough_hosts { + match h { + HostPattern::Exact(s) => passthrough_hosts.push(s), + HostPattern::Wildcard(s) => passthrough_host_patterns.push(s), + HostPattern::Any => unreachable!( + "passthrough_hosts cannot contain HostPattern::Any; builder only emits exact/wildcard patterns" + ), + } + } JsSecretEntry { env_var: entry.env_var, value: entry.value, placeholder: entry.placeholder, allowed_hosts, allowed_host_patterns, + passthrough_hosts, + passthrough_host_patterns, allow_any_host, require_tls_identity: entry.require_tls_identity, injection: JsSecretInjection { diff --git a/sdk/node-ts/src/internal/napi.ts b/sdk/node-ts/src/internal/napi.ts index 6dcf85420..93ccffbd3 100644 --- a/sdk/node-ts/src/internal/napi.ts +++ b/sdk/node-ts/src/internal/napi.ts @@ -626,6 +626,8 @@ export interface NapiSecretBuilder { allowHost(host: string): this; allowHostPattern(pattern: string): this; allowAnyHostDangerous(iUnderstand: boolean): this; + allowPassthroughHost(host: string): this; + allowPassthroughHostPattern(pattern: string): this; requireTlsIdentity(enabled: boolean): this; injectHeaders(enabled: boolean): this; injectBasicAuth(enabled: boolean): this; @@ -640,6 +642,8 @@ export interface NapiSecretEntry { readonly placeholder: string; readonly allowedHosts: string[]; readonly allowedHostPatterns: string[]; + readonly passthroughHosts: string[]; + readonly passthroughHostPatterns: string[]; readonly allowAnyHost: boolean; readonly requireTlsIdentity: boolean; readonly injection: NapiSecretInjection; @@ -669,6 +673,8 @@ export interface NapiNetworkBuilder { configure: (b: NapiInterfaceOverridesBuilder) => NapiInterfaceOverridesBuilder, ): this; onSecretViolation(action: string): this; + allowSecretPassthroughHost(host: string): this; + allowSecretPassthroughHostPattern(pattern: string): this; maxConnections(max: number): this; ipv4Pool(pool: string): this; ipv6Pool(pool: string): this; diff --git a/sdk/node-ts/src/network-config.ts b/sdk/node-ts/src/network-config.ts index 9f386a8d4..7f732993d 100644 --- a/sdk/node-ts/src/network-config.ts +++ b/sdk/node-ts/src/network-config.ts @@ -42,6 +42,8 @@ export interface SecretEntry { readonly placeholder: string | null; readonly allowedHosts: readonly string[]; readonly allowedHostPatterns: readonly string[]; + readonly passthroughHosts: readonly string[]; + readonly passthroughHostPatterns: readonly string[]; readonly allowAnyHost: boolean; readonly requireTlsIdentity: boolean; readonly injection: SecretInjection; diff --git a/sdk/node-ts/src/violation-action.ts b/sdk/node-ts/src/violation-action.ts index ecd78abae..409a864ae 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 1c48e8ffa..07e54592c 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,34 @@ describe("NetworkBuilder.secretEnvSimple (3-arg shorthand)", () => { }); }); +describe("NetworkBuilder secret passthrough", () => { + it("builds global passthrough host allowlists", () => { + const cfg = new NetworkBuilder() + .allowSecretPassthroughHost("api.anthropic.com") + .allowSecretPassthroughHostPattern("*.anthropic.com") + .build() as { + secrets: { + passthroughHosts: unknown[]; + }; + }; + + expect(cfg.secrets.passthroughHosts).toHaveLength(2); + }); + + it("builds per-secret passthrough host allowlists", () => { + const secret = new SecretBuilder() + .env("API_KEY") + .value("sk-abc") + .allowHost("api.github.com") + .allowPassthroughHost("api.anthropic.com") + .allowPassthroughHostPattern("*.anthropic.com") + .build(); + + expect(secret.passthroughHosts).toEqual(["api.anthropic.com"]); + expect(secret.passthroughHostPatterns).toEqual(["*.anthropic.com"]); + }); +}); + describe("NetworkBuilder ports", () => { it("keeps loopback default and supports explicit bind addresses", () => { const cfg = new NetworkBuilder() diff --git a/sdk/python/microsandbox/types.py b/sdk/python/microsandbox/types.py index 3cd0777ea..08b8f8a38 100644 --- a/sdk/python/microsandbox/types.py +++ b/sdk/python/microsandbox/types.py @@ -68,6 +68,7 @@ class ViolationAction(enum.StrEnum): BLOCK = "block" BLOCK_AND_LOG = "block-and-log" BLOCK_AND_TERMINATE = "block-and-terminate" + PASSTHROUGH = "passthrough" class MountKind(enum.StrEnum): BIND = "bind" @@ -506,9 +507,10 @@ class SecretEntry: value: str allow_hosts: tuple[str, ...] = () allow_host_patterns: tuple[str, ...] = () + passthrough_hosts: tuple[str, ...] = () + passthrough_host_patterns: tuple[str, ...] = () placeholder: str | None = None require_tls: bool = True - on_violation: ViolationAction = ViolationAction.BLOCK_AND_LOG injection: SecretInjection = field(default_factory=SecretInjection) def _to_dict(self) -> dict: @@ -517,12 +519,14 @@ def _to_dict(self) -> dict: d["allow_hosts"] = list(self.allow_hosts) if self.allow_host_patterns: d["allow_host_patterns"] = list(self.allow_host_patterns) + if self.passthrough_hosts: + d["passthrough_hosts"] = list(self.passthrough_hosts) + if self.passthrough_host_patterns: + d["passthrough_host_patterns"] = list(self.passthrough_host_patterns) if self.placeholder is not None: 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) injection = self.injection._to_dict() if injection: d["injection"] = injection @@ -538,9 +542,10 @@ def env( value: str, allow_hosts: Sequence[str] = (), allow_host_patterns: Sequence[str] = (), + passthrough_hosts: Sequence[str] = (), + passthrough_host_patterns: Sequence[str] = (), placeholder: str | None = None, require_tls: bool = True, - on_violation: ViolationAction = ViolationAction.BLOCK_AND_LOG, injection: SecretInjection | None = None, ) -> SecretEntry: return SecretEntry( @@ -548,9 +553,10 @@ def env( value=value, allow_hosts=tuple(allow_hosts), allow_host_patterns=tuple(allow_host_patterns), + passthrough_hosts=tuple(passthrough_hosts), + passthrough_host_patterns=tuple(passthrough_host_patterns), placeholder=placeholder, require_tls=require_tls, - on_violation=on_violation, injection=injection if injection is not None else SecretInjection(), ) @@ -727,6 +733,9 @@ 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 = ViolationAction.BLOCK_AND_LOG + secret_passthrough_hosts: tuple[str, ...] = () + secret_passthrough_host_patterns: tuple[str, ...] = () @classmethod def none(cls) -> Network: @@ -768,6 +777,12 @@ def _to_dict(self) -> dict: d["ipv6_pool"] = self.ipv6_pool if self.max_connections is not None: d["max_connections"] = self.max_connections + if self.on_secret_violation != ViolationAction.BLOCK_AND_LOG: + d["on_secret_violation"] = str(self.on_secret_violation) + if self.secret_passthrough_hosts: + d["secret_passthrough_hosts"] = list(self.secret_passthrough_hosts) + if self.secret_passthrough_host_patterns: + d["secret_passthrough_host_patterns"] = list(self.secret_passthrough_host_patterns) return d #-------------------------------------------------------------------------------------------------- diff --git a/sdk/python/src/helpers.rs b/sdk/python/src/helpers.rs index 032240b37..b83695203 100644 --- a/sdk/python/src/helpers.rs +++ b/sdk/python/src/helpers.rs @@ -832,6 +832,23 @@ fn apply_network( builder = builder.network(|n| n.on_secret_violation(action)); } + // Secret passthrough hosts (sandbox-level). + let secret_passthrough_hosts: Vec = + extract_opt(net, "secret_passthrough_hosts")?.unwrap_or_default(); + let secret_passthrough_host_patterns: Vec = + extract_opt(net, "secret_passthrough_host_patterns")?.unwrap_or_default(); + if !secret_passthrough_hosts.is_empty() || !secret_passthrough_host_patterns.is_empty() { + builder = builder.network(|mut n| { + for host in &secret_passthrough_hosts { + n = n.allow_secret_passthrough_host(host); + } + for pattern in &secret_passthrough_host_patterns { + n = n.allow_secret_passthrough_host_pattern(pattern); + } + n + }); + } + // TLS config. if let Some(tls) = net.get_item("tls")? && !tls.is_none() @@ -935,6 +952,10 @@ 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 passthrough_hosts: Vec = + extract_opt(secret, "passthrough_hosts")?.unwrap_or_default(); + let passthrough_host_patterns: Vec = + extract_opt(secret, "passthrough_host_patterns")?.unwrap_or_default(); let placeholder: Option = extract_opt(secret, "placeholder")?; let require_tls: Option = extract_opt(secret, "require_tls")?; @@ -960,6 +981,12 @@ fn apply_secret( for pattern in &allow_host_patterns { s = s.allow_host_pattern(pattern); } + for host in &passthrough_hosts { + s = s.allow_passthrough_host(host); + } + for pattern in &passthrough_host_patterns { + s = s.allow_passthrough_host_pattern(pattern); + } if let Some(ref ph) = placeholder { s = s.placeholder(ph); } @@ -1016,6 +1043,7 @@ fn parse_violation_action( "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), _ => Err(pyo3::exceptions::PyValueError::new_err(format!( "unknown violation action: {s}" ))), diff --git a/sdk/python/tests/test_secret_passthrough.py b/sdk/python/tests/test_secret_passthrough.py new file mode 100644 index 000000000..2fc61aa12 --- /dev/null +++ b/sdk/python/tests/test_secret_passthrough.py @@ -0,0 +1,41 @@ +"""Unit tests for secret placeholder passthrough configuration.""" + +from __future__ import annotations + +from microsandbox import Network, Secret, ViolationAction + + +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",), + passthrough_hosts=("api.anthropic.com",), + passthrough_host_patterns=("*.anthropic.com",), + ) + + assert secret._to_dict() == { + "env_var": "API_KEY", + "value": "sk-abc", + "allow_hosts": ["api.github.com"], + "passthrough_hosts": ["api.anthropic.com"], + "passthrough_host_patterns": ["*.anthropic.com"], + } + + +def test_network_secret_passthrough_hosts_serialize() -> None: + network = Network( + on_secret_violation=ViolationAction.PASSTHROUGH, + secret_passthrough_hosts=("api.anthropic.com",), + secret_passthrough_host_patterns=("*.anthropic.com",), + ) + + assert network._to_dict() == { + "on_secret_violation": "passthrough", + "secret_passthrough_hosts": ["api.anthropic.com"], + "secret_passthrough_host_patterns": ["*.anthropic.com"], + } diff --git a/skills b/skills index c7c02dd0b..c0a25d935 160000 --- a/skills +++ b/skills @@ -1 +1 @@ -Subproject commit c7c02dd0be559aa5a182e0e0e5a4378f07c196f3 +Subproject commit c0a25d935c9e717cf40126f1cdca80043cc9ee8a From bef71b6acbe79c291d8fa9fbda8c0a479344b179 Mon Sep 17 00:00:00 2001 From: Wells Bunker Date: Thu, 21 May 2026 13:03:00 -0600 Subject: [PATCH 2/6] update network and secret to have on_violation method that takes a violation policy builder --- crates/cli/lib/commands/common.rs | 13 +- crates/network/lib/builder.rs | 237 +++++++++++++----- crates/network/lib/secrets/config.rs | 36 +-- crates/network/lib/secrets/handler.rs | 166 ++++++++---- docs/sdk/python/networking.mdx | 8 +- docs/sdk/python/secrets.mdx | 23 +- docs/sdk/rust/networking.mdx | 38 +-- docs/sdk/rust/secrets.mdx | 63 +++-- docs/sdk/typescript/networking.mdx | 34 +-- docs/sdk/typescript/secrets.mdx | 7 +- sdk/node-ts/native/index.cjs | 2 + sdk/node-ts/native/index.d.ts | 39 +-- sdk/node-ts/native/lib.rs | 1 + sdk/node-ts/native/network_builder.rs | 52 ++-- sdk/node-ts/native/secret_builder.rs | 63 ++--- .../native/violation_action_builder.rs | 82 ++++++ sdk/node-ts/src/internal/napi.ts | 23 +- sdk/node-ts/src/network-config.ts | 2 - sdk/node-ts/tests/unit/builders.test.ts | 33 ++- sdk/python/microsandbox/__init__.py | 2 + sdk/python/microsandbox/types.py | 88 +++++-- sdk/python/src/helpers.rs | 121 ++++++--- sdk/python/tests/test_secret_passthrough.py | 31 ++- 23 files changed, 790 insertions(+), 374 deletions(-) create mode 100644 sdk/node-ts/native/violation_action_builder.rs diff --git a/crates/cli/lib/commands/common.rs b/crates/cli/lib/commands/common.rs index 8098b889c..fa24b73e7 100644 --- a/crates/cli/lib/commands/common.rs +++ b/crates/cli/lib/commands/common.rs @@ -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,13 +963,16 @@ 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, PassthroughPolicy, 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)), + Some("passthrough") => Ok(Some(ViolationAction::Passthrough(PassthroughPolicy { + hosts: vec![HostPattern::Any], + fallback: Box::new(ViolationAction::BlockAndLog), + }))), Some(other) => anyhow::bail!( "invalid violation action: {other} (expected: block, block-and-log, block-and-terminate, passthrough)" ), @@ -1263,7 +1268,7 @@ mod tests { assert!(matches!( action, - microsandbox_network::secrets::config::ViolationAction::Passthrough + microsandbox_network::secrets::config::ViolationAction::Passthrough(_) )); } diff --git a/crates/network/lib/builder.rs b/crates/network/lib/builder.rs index 6a2903779..ffa138fab 100644 --- a/crates/network/lib/builder.rs +++ b/crates/network/lib/builder.rs @@ -10,7 +10,9 @@ use ipnetwork::{Ipv4Network, Ipv6Network}; use crate::config::{DnsConfig, InterfaceOverrides, NetworkConfig, PortProtocol, PublishedPort}; use crate::dns::Nameserver; use crate::policy::{BuildError, NetworkPolicy}; -use crate::secrets::config::{HostPattern, SecretEntry, SecretInjection, ViolationAction}; +use crate::secrets::config::{ + HostPattern, PassthroughPolicy, SecretEntry, SecretInjection, ViolationAction, +}; use crate::tls::TlsConfig; //-------------------------------------------------------------------------------------------------- @@ -48,11 +50,17 @@ pub struct SecretBuilder { value: Option, placeholder: Option, allowed_hosts: Vec, - passthrough_hosts: Vec, injection: SecretInjection, + on_violation: ViolationAction, require_tls_identity: bool, } +/// Fluent builder for a [`ViolationAction`]. +pub struct ViolationActionBuilder { + fallback: ViolationAction, + passthrough_hosts: Vec, +} + //-------------------------------------------------------------------------------------------------- // Methods //-------------------------------------------------------------------------------------------------- @@ -179,34 +187,19 @@ impl NetworkBuilder { value: value.into(), placeholder: placeholder.into(), allowed_hosts: vec![HostPattern::Exact(allowed_host.into())], - passthrough_hosts: Vec::new(), injection: SecretInjection::default(), + on_violation: ViolationAction::default(), 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; - self - } - - /// Allow a host to receive secret placeholders without substitution. - pub fn allow_secret_passthrough_host(mut self, host: impl Into) -> Self { - self.config - .secrets - .passthrough_hosts - .push(HostPattern::Exact(host.into())); - self - } - - /// Allow hosts matching a wildcard pattern to receive secret placeholders without substitution. - pub fn allow_secret_passthrough_host_pattern(mut self, pattern: impl Into) -> Self { - self.config - .secrets - .passthrough_hosts - .push(HostPattern::Wildcard(pattern.into())); + pub fn on_secret_violation( + mut self, + f: impl FnOnce(ViolationActionBuilder) -> ViolationActionBuilder, + ) -> Self { + self.config.secrets.on_violation = f(ViolationActionBuilder::new()).build(); self } @@ -390,8 +383,8 @@ impl SecretBuilder { value: None, placeholder: None, allowed_hosts: Vec::new(), - passthrough_hosts: Vec::new(), injection: SecretInjection::default(), + on_violation: ViolationAction::default(), require_tls_identity: true, } } @@ -437,16 +430,12 @@ impl SecretBuilder { self } - /// Allow a host to receive this secret's placeholder without substitution. - pub fn allow_passthrough_host(mut self, host: impl Into) -> Self { - self.passthrough_hosts.push(HostPattern::Exact(host.into())); - self - } - - /// Allow hosts matching a wildcard pattern to receive this secret's placeholder without substitution. - pub fn allow_passthrough_host_pattern(mut self, pattern: impl Into) -> Self { - self.passthrough_hosts - .push(HostPattern::Wildcard(pattern.into())); + /// Set the violation action for this secret. + pub fn on_violation( + mut self, + f: impl FnOnce(ViolationActionBuilder) -> ViolationActionBuilder, + ) -> Self { + self.on_violation = f(ViolationActionBuilder::new()).build(); self } @@ -496,13 +485,91 @@ impl SecretBuilder { value, placeholder, allowed_hosts: self.allowed_hosts, - passthrough_hosts: self.passthrough_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 { + fallback: ViolationAction::default(), + passthrough_hosts: Vec::new(), + } + } + + /// Start building from an existing action. + pub fn from_action(action: ViolationAction) -> Self { + match action { + ViolationAction::Passthrough(policy) => Self { + fallback: *policy.fallback, + passthrough_hosts: policy.hosts, + }, + action => Self { + fallback: action, + passthrough_hosts: Vec::new(), + }, + } + } + + /// Block the request silently. + pub fn block(mut self) -> Self { + self.fallback = ViolationAction::Block; + self + } + + /// Block the request and emit a warning log. + pub fn block_and_log(mut self) -> Self { + self.fallback = ViolationAction::BlockAndLog; + self + } + + /// Block the request and terminate the sandbox. + pub fn block_and_terminate(mut self) -> Self { + self.fallback = 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 + } + + /// Consume the builder and return the action. + pub fn build(self) -> ViolationAction { + if self.passthrough_hosts.is_empty() { + return self.fallback; + } + + ViolationAction::Passthrough(PassthroughPolicy { + hosts: self.passthrough_hosts, + fallback: Box::new(self.fallback), + }) + } + + fn push_passthrough_host(&mut self, host: HostPattern) { + self.passthrough_hosts.push(host); + } +} + //-------------------------------------------------------------------------------------------------- // Trait Implementations //-------------------------------------------------------------------------------------------------- @@ -525,6 +592,12 @@ impl Default for SecretBuilder { } } +impl Default for ViolationActionBuilder { + fn default() -> Self { + Self::new() + } +} + //-------------------------------------------------------------------------------------------------- // Tests //-------------------------------------------------------------------------------------------------- @@ -561,42 +634,88 @@ mod tests { } #[test] - fn network_builder_sets_global_passthrough_hosts() { + fn network_builder_sets_global_passthrough_action() { let cfg = NetworkBuilder::new() - .allow_secret_passthrough_host("api.anthropic.com") - .allow_secret_passthrough_host_pattern("*.anthropic.com") + .on_secret_violation(|v| { + v.passthrough_host("api.anthropic.com") + .passthrough_host_pattern("*.anthropic.com") + }) .build() .unwrap(); - assert_eq!(cfg.secrets.passthrough_hosts.len(), 2); - assert!(matches!( - &cfg.secrets.passthrough_hosts[0], - HostPattern::Exact(host) if host == "api.anthropic.com" - )); - assert!(matches!( - &cfg.secrets.passthrough_hosts[1], - HostPattern::Wildcard(pattern) if pattern == "*.anthropic.com" - )); + assert_eq!( + cfg.secrets.on_violation, + ViolationAction::Passthrough(PassthroughPolicy { + hosts: vec![ + HostPattern::Exact("api.anthropic.com".into()), + HostPattern::Wildcard("*.anthropic.com".into()), + ], + fallback: Box::new(ViolationAction::BlockAndLog), + }) + ); } #[test] - fn secret_builder_sets_passthrough_hosts() { + fn secret_builder_sets_violation_action() { let secret = SecretBuilder::new() .env("TOKEN") .value("secret-value") .allow_host("api.github.com") - .allow_passthrough_host("api.anthropic.com") - .allow_passthrough_host_pattern("*.anthropic.com") + .on_violation(|v| { + v.passthrough_host("api.anthropic.com") + .passthrough_host_pattern("*.anthropic.com") + }) + .build(); + + assert_eq!( + secret.on_violation, + ViolationAction::Passthrough(PassthroughPolicy { + hosts: vec![ + HostPattern::Exact("api.anthropic.com".into()), + HostPattern::Wildcard("*.anthropic.com".into()), + ], + fallback: Box::new(ViolationAction::BlockAndLog), + }) + ); + } + + #[test] + fn violation_action_builder_preserves_fallback_action() { + let action = ViolationActionBuilder::new() + .passthrough_host("google.com") + .block_and_terminate() + .passthrough_host("facebook.com") + .build(); + + assert_eq!( + action, + ViolationAction::Passthrough(PassthroughPolicy { + hosts: vec![ + HostPattern::Exact("google.com".into()), + HostPattern::Exact("facebook.com".into()), + ], + fallback: Box::new(ViolationAction::BlockAndTerminate), + }) + ); + } + + #[test] + fn violation_action_builder_accumulates_passthrough_hosts() { + let action = ViolationActionBuilder::new() + .block() + .passthrough_host("google.com") + .passthrough_host("facebook.com") .build(); - assert_eq!(secret.passthrough_hosts.len(), 2); - assert!(matches!( - &secret.passthrough_hosts[0], - HostPattern::Exact(host) if host == "api.anthropic.com" - )); - assert!(matches!( - &secret.passthrough_hosts[1], - HostPattern::Wildcard(pattern) if pattern == "*.anthropic.com" - )); + assert_eq!( + action, + ViolationAction::Passthrough(PassthroughPolicy { + hosts: vec![ + HostPattern::Exact("google.com".into()), + HostPattern::Exact("facebook.com".into()), + ], + fallback: Box::new(ViolationAction::Block), + }) + ); } } diff --git a/crates/network/lib/secrets/config.rs b/crates/network/lib/secrets/config.rs index 81de316bb..08571a9fd 100644 --- a/crates/network/lib/secrets/config.rs +++ b/crates/network/lib/secrets/config.rs @@ -13,10 +13,6 @@ pub struct SecretsConfig { #[serde(default)] pub secrets: Vec, - /// Hosts allowed to receive secret placeholders without substitution. - #[serde(default)] - pub passthrough_hosts: Vec, - /// Action on secret violation (placeholder leaked to disallowed host). #[serde(default)] pub on_violation: ViolationAction, @@ -38,14 +34,14 @@ pub struct SecretEntry { #[serde(default)] pub allowed_hosts: Vec, - /// Hosts allowed to receive this secret's placeholder without substitution. - #[serde(default)] - pub passthrough_hosts: Vec, - /// Where the secret can be injected. #[serde(default)] pub injection: SecretInjection, + /// Action on secret violation for this secret. + #[serde(default)] + pub on_violation: ViolationAction, + /// 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. @@ -54,7 +50,7 @@ pub struct SecretEntry { } /// Host pattern for secret allowlist. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum HostPattern { /// Exact hostname match. Exact(String), @@ -85,7 +81,7 @@ pub struct SecretInjection { } /// Action when a secret placeholder is detected going to a disallowed host. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum ViolationAction { /// Block the request silently. Block, @@ -94,9 +90,21 @@ pub enum ViolationAction { BlockAndLog, /// Block and terminate the sandbox. BlockAndTerminate, - /// Forward the request with the placeholder unchanged. + /// Forward the request with the placeholder unchanged for matching hosts. #[serde(rename = "passthrough")] - Passthrough, + Passthrough(PassthroughPolicy), +} + +/// Hosts that may receive placeholders unchanged, plus the fallback action for +/// non-matching hosts. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PassthroughPolicy { + /// Hosts allowed to receive placeholders unchanged. + #[serde(default)] + pub hosts: Vec, + /// Action for hosts that do not match `hosts`. + #[serde(default)] + pub fallback: Box, } //-------------------------------------------------------------------------------------------------- @@ -110,8 +118,8 @@ impl std::fmt::Debug for SecretEntry { .field("value", &"[REDACTED]") .field("placeholder", &self.placeholder) .field("allowed_hosts", &self.allowed_hosts) - .field("passthrough_hosts", &self.passthrough_hosts) .field("injection", &self.injection) + .field("on_violation", &self.on_violation) .field("require_tls_identity", &self.require_tls_identity) .finish() } @@ -210,8 +218,8 @@ mod tests { value: "v".into(), placeholder: "$K".into(), allowed_hosts: vec![], - passthrough_hosts: vec![], injection: SecretInjection::default(), + on_violation: ViolationAction::default(), require_tls_identity: true, }; assert!(entry.require_tls_identity); diff --git a/crates/network/lib/secrets/handler.rs b/crates/network/lib/secrets/handler.rs index eb988ebd0..29b1f1d73 100644 --- a/crates/network/lib/secrets/handler.rs +++ b/crates/network/lib/secrets/handler.rs @@ -21,11 +21,11 @@ use super::config::{SecretsConfig, ViolationAction}; pub struct SecretsHandler { /// Secrets eligible for substitution on this connection. eligible: Vec, - /// Secret placeholders allowed to pass through unchanged on this connection. - eligible_passthrough: Vec, + /// Secret placeholders that should use a per-secret fallback action. + ineligible: Vec, /// All placeholder strings (for violation detection on disallowed hosts). all_placeholders: Vec, - /// Violation action. + /// Global violation action. on_violation: ViolationAction, /// Whether any disallowed placeholders exist (pre-computed for fast-path skip). has_ineligible: bool, @@ -50,6 +50,12 @@ struct EligibleSecret { require_tls_identity: bool, } +/// A secret that did not pass substitution or passthrough host matching. +struct IneligibleSecret { + placeholder: String, + fallback: ViolationAction, +} + //-------------------------------------------------------------------------------------------------- // Methods //-------------------------------------------------------------------------------------------------- @@ -124,9 +130,9 @@ impl SecretsHandler { /// (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 eligible_passthrough = Vec::new(); + let mut ineligible = Vec::new(); let mut all_placeholders = Vec::new(); - let global_passthrough_allowed = config.passthrough_hosts.iter().any(|p| p.matches(sni)); + let global_decision = violation_decision(&config.on_violation, sni); for secret in &config.secrets { all_placeholders.push(secret.placeholder.clone()); @@ -145,22 +151,24 @@ impl SecretsHandler { require_tls_identity: secret.require_tls_identity, }); } else { - let secret_passthrough_allowed = - secret.passthrough_hosts.iter().any(|p| p.matches(sni)); - if global_passthrough_allowed || secret_passthrough_allowed { - eligible_passthrough.push(secret.placeholder.clone()); + let secret_decision = violation_decision(&secret.on_violation, sni); + if !(secret_decision.passthrough || global_decision.passthrough) { + ineligible.push(IneligibleSecret { + placeholder: secret.placeholder.clone(), + fallback: secret_decision.fallback, + }); } } } - let has_ineligible = eligible.len() + eligible_passthrough.len() < all_placeholders.len(); + let has_ineligible = !ineligible.is_empty(); let max_placeholder_len = all_placeholders.iter().map(String::len).max().unwrap_or(0); Self { eligible, - eligible_passthrough, + ineligible, all_placeholders, - on_violation: config.on_violation, + on_violation: global_decision.fallback, has_ineligible, tls_intercepted, max_placeholder_len, @@ -194,8 +202,8 @@ impl SecretsHandler { }; // Fast path: skip violation check when no ineligible secrets exist. - if self.has_ineligible && self.has_violation(data, &header_str) { - match self.on_violation { + if let Some(action) = self.detect_violation_action(data, &header_str) { + match action { ViolationAction::Block => { self.update_tail(data); return None; @@ -212,7 +220,9 @@ impl SecretsHandler { ); return None; } - ViolationAction::Passthrough => {} + ViolationAction::Passthrough(policy) => { + debug_assert!(policy.hosts.is_empty()); + } } } self.update_tail(data); @@ -255,17 +265,16 @@ impl SecretsHandler { matches!(self.on_violation, ViolationAction::BlockAndTerminate) } - /// Check if any placeholder appears in data for a host that isn't allowed - /// to receive either the real secret or the placeholder. + /// Returns the strictest 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 every placeholder is either substitutable or explicitly - // allowed to pass through on this host, no violation is possible. + fn detect_violation_action(&self, data: &[u8], headers: &str) -> Option { if !self.has_ineligible { - return false; + return None; } let scan_buf: Cow<[u8]> = if self.prev_tail.is_empty() { @@ -277,24 +286,26 @@ impl SecretsHandler { Cow::Owned(stitched) }; let scan = scan_buf.as_ref(); + let mut detected = None; - for placeholder in &self.all_placeholders { - if self.eligible.iter().any(|s| s.placeholder == *placeholder) - || self.eligible_passthrough.iter().any(|p| p == placeholder) - { - continue; - } - let needle = placeholder.as_bytes(); + for secret in &self.ineligible { + 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.fallback.clone(), + )); + if detected == Some(ViolationAction::BlockAndTerminate) { + break; + } } } - false + detected } /// Update the sliding-window tail with the trailing bytes of `data`, so @@ -441,6 +452,41 @@ fn find_header_boundary(data: &[u8]) -> Option { .map(|pos| pos + 4) } +struct ViolationDecision { + passthrough: bool, + fallback: ViolationAction, +} + +fn violation_decision(action: &ViolationAction, sni: &str) -> ViolationDecision { + match action { + ViolationAction::Passthrough(policy) => ViolationDecision { + passthrough: policy.hosts.iter().any(|p| p.matches(sni)), + fallback: (*policy.fallback).clone(), + }, + action => ViolationDecision { + passthrough: false, + fallback: action.clone(), + }, + } +} + +fn strictest_violation_action( + current: Option, + candidate: ViolationAction, +) -> ViolationAction { + match (current, candidate) { + (Some(ViolationAction::BlockAndTerminate), _) | (_, ViolationAction::BlockAndTerminate) => { + ViolationAction::BlockAndTerminate + } + (Some(ViolationAction::BlockAndLog), _) | (_, ViolationAction::BlockAndLog) => { + ViolationAction::BlockAndLog + } + (Some(ViolationAction::Block), _) | (_, ViolationAction::Block) => ViolationAction::Block, + (Some(ViolationAction::Passthrough(_)), ViolationAction::Passthrough(policy)) + | (None, ViolationAction::Passthrough(policy)) => ViolationAction::Passthrough(policy), + } +} + //-------------------------------------------------------------------------------------------------- // Tests //-------------------------------------------------------------------------------------------------- @@ -453,7 +499,6 @@ mod tests { fn make_config(secrets: Vec) -> SecretsConfig { SecretsConfig { secrets, - passthrough_hosts: vec![], on_violation: ViolationAction::Block, } } @@ -464,8 +509,8 @@ mod tests { value: value.into(), placeholder: placeholder.into(), allowed_hosts: vec![HostPattern::Exact(host.into())], - passthrough_hosts: vec![], injection: SecretInjection::default(), + on_violation: ViolationAction::default(), require_tls_identity: true, } } @@ -504,9 +549,10 @@ mod tests { #[test] fn global_passthrough_host_forwards_placeholder_unchanged() { let mut config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]); - config - .passthrough_hosts - .push(HostPattern::Exact("api.anthropic.com".into())); + config.on_violation = ViolationAction::Passthrough(PassthroughPolicy { + hosts: vec![HostPattern::Exact("api.anthropic.com".into())], + fallback: Box::new(ViolationAction::Block), + }); 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"; @@ -518,9 +564,10 @@ mod tests { #[test] fn per_secret_passthrough_host_forwards_placeholder_unchanged() { let mut secret = make_secret("$KEY", "real-secret", "api.openai.com"); - secret - .passthrough_hosts - .push(HostPattern::Exact("api.anthropic.com".into())); + secret.on_violation = ViolationAction::Passthrough(PassthroughPolicy { + hosts: vec![HostPattern::Exact("api.anthropic.com".into())], + fallback: Box::new(ViolationAction::Block), + }); let config = make_config(vec![secret]); let mut handler = SecretsHandler::new(&config, "api.anthropic.com", true); @@ -532,7 +579,10 @@ mod tests { #[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; + config.on_violation = ViolationAction::Passthrough(PassthroughPolicy { + hosts: vec![HostPattern::Any], + fallback: Box::new(ViolationAction::Block), + }); let mut handler = SecretsHandler::new(&config, "evil.com", true); let input = b"GET / HTTP/1.1\r\nAuthorization: Bearer $KEY\r\n\r\n"; @@ -543,9 +593,10 @@ mod tests { #[test] fn passthrough_host_does_not_allow_other_disallowed_placeholders() { let mut passthrough = make_secret("$PASSTHROUGH", "real-secret-a", "api.openai.com"); - passthrough - .passthrough_hosts - .push(HostPattern::Exact("api.anthropic.com".into())); + passthrough.on_violation = ViolationAction::Passthrough(PassthroughPolicy { + hosts: vec![HostPattern::Exact("api.anthropic.com".into())], + fallback: Box::new(ViolationAction::Block), + }); 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); @@ -554,6 +605,35 @@ mod tests { assert!(handler.substitute(input).is_none()); } + #[test] + fn passthrough_uses_secret_fallback_for_non_matching_host() { + let mut secret = make_secret("$KEY", "real-secret", "api.openai.com"); + secret.on_violation = ViolationAction::Passthrough(PassthroughPolicy { + hosts: vec![HostPattern::Exact("api.anthropic.com".into())], + fallback: Box::new(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!(handler.substitute(input).is_none()); + assert!(!handler.terminates_on_violation()); + } + + #[test] + fn passthrough_uses_global_fallback_for_non_matching_host() { + let mut config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]); + config.on_violation = ViolationAction::Passthrough(PassthroughPolicy { + hosts: vec![HostPattern::Exact("api.anthropic.com".into())], + fallback: Box::new(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!(handler.substitute(input).is_none()); + assert!(handler.terminates_on_violation()); + } + #[test] fn global_block_and_terminate_marks_violation_as_terminating() { let mut config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]); diff --git a/docs/sdk/python/networking.mdx b/docs/sdk/python/networking.mdx index 4e9e59ff8..1de81eeb7 100644 --- a/docs/sdk/python/networking.mdx +++ b/docs/sdk/python/networking.mdx @@ -21,9 +21,7 @@ Network( ipv4_pool: str | None = None, ipv6_pool: str | None = None, max_connections: int | None = None, - on_secret_violation: ViolationAction = ViolationAction.BLOCK_AND_LOG, - secret_passthrough_hosts: tuple[str, ...] = (), - secret_passthrough_host_patterns: tuple[str, ...] = (), + on_secret_violation: ViolationAction | ViolationPolicy = ViolationAction.BLOCK_AND_LOG, trust_host_cas: bool | None = None, ) ``` @@ -39,9 +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) | Sandbox-wide action when a secret placeholder reaches a disallowed host | -| secret_passthrough_hosts | `tuple[str, ...]` | Hosts allowed to receive secret placeholders unchanged. Does not enable substitution | -| secret_passthrough_host_patterns | `tuple[str, ...]` | Wildcard host patterns allowed to receive secret placeholders unchanged. Does not enable substitution | +| 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 3fcc97bcb..56774ffdf 100644 --- a/docs/sdk/python/secrets.mdx +++ b/docs/sdk/python/secrets.mdx @@ -22,10 +22,9 @@ def env( value: str, allow_hosts: Sequence[str] = (), allow_host_patterns: Sequence[str] = (), - passthrough_hosts: Sequence[str] = (), - passthrough_host_patterns: Sequence[str] = (), placeholder: str | None = None, require_tls: bool = True, + on_violation: ViolationAction | ViolationPolicy = ViolationAction.BLOCK_AND_LOG, injection: SecretInjection | None = None, ) -> SecretEntry ``` @@ -40,10 +39,9 @@ Create a secret entry that maps an environment variable to a real value. The gue | value | `str` | - | The real secret value. Never enters the guest VM. **Required.** | | allow_hosts | `Sequence[str]` | `()` | Hosts allowed to receive the real value (exact match). The TLS proxy matches against the SNI. | | allow_host_patterns | `Sequence[str]` | `()` | Wildcard host patterns (e.g. `"*.googleapis.com"`) | -| passthrough_hosts | `Sequence[str]` | `()` | Hosts allowed to receive the placeholder unchanged. Does not enable substitution. | -| passthrough_host_patterns | `Sequence[str]` | `()` | Wildcard host patterns allowed to receive the placeholder unchanged. Does not enable substitution. | | 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) `\|` [`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** @@ -66,10 +64,9 @@ Frozen dataclass returned by [`Secret.env()`](#secretenv) and used in `SandboxCo | value | `str` | Secret value | | allow_hosts | `tuple[str, ...]` | Allowed hosts (exact match) | | allow_host_patterns | `tuple[str, ...]` | Wildcard patterns | -| passthrough_hosts | `tuple[str, ...]` | Hosts allowed to receive the placeholder unchanged | -| passthrough_host_patterns | `tuple[str, ...]` | Wildcard patterns allowed to receive the placeholder unchanged | | placeholder | `str \| None` | Placeholder string | | require_tls | `bool` | TLS requirement | +| on_violation | [`ViolationAction`](#violationaction) `\|` [`ViolationPolicy`](#violationpolicy) | Per-secret violation behavior | | injection | [`SecretInjection`](#secretinjection) | Per-request injection scopes | ### SecretInjection @@ -92,4 +89,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 the request with the placeholder unchanged. The real secret is not substituted. | +| `"passthrough"` | Forward matching hosts with the placeholder unchanged; use the configured fallback for non-matching hosts. | + +### 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",), + fallback=ViolationAction.BLOCK_AND_LOG, +) +``` diff --git a/docs/sdk/rust/networking.mdx b/docs/sdk/rust/networking.mdx index d909549a4..ce740fc06 100644 --- a/docs/sdk/rust/networking.mdx +++ b/docs/sdk/rust/networking.mdx @@ -638,30 +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). - ---- - -#### allow_secret_passthrough_host() +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 fallback and optional passthrough hosts: ```rust -fn allow_secret_passthrough_host(self, host: impl Into) -> Self +.network(|n| n.on_secret_violation(|v| { + v.block_and_log() + .passthrough_host("api.anthropic.com") + .passthrough_host_pattern("*.example.com") +})) ``` -Allow any configured secret placeholder to pass through unchanged to an exact host. This does **not** allow the host to receive the real secret value; substitution still only happens for hosts configured with `SecretBuilder::allow_host()`. +Passthrough hosts receive the placeholder unchanged. They do **not** receive real secret values. --- -#### allow_secret_passthrough_host_pattern() +## ViolationActionBuilder -```rust -fn allow_secret_passthrough_host_pattern(self, pattern: impl Into) -> Self -``` +Builder for secret violation behavior. Used by [`NetworkBuilder::on_secret_violation()`](#on_secret_violation) and `SecretBuilder::on_violation(...)`. + +| Method | Description | +|--------|-------------| +| `block()` | Use `Block` as the fallback action | +| `block_and_log()` | Use `BlockAndLog` as the fallback action | +| `block_and_terminate()` | Use `BlockAndTerminate` as the fallback action | +| `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 | -Allow any configured secret placeholder to pass through unchanged to hosts matching a wildcard pattern (for example, `"*.anthropic.com"`). This does **not** allow those hosts to receive real secret values. +Passthrough host calls accumulate. Blocking calls update the fallback action and keep any passthrough hosts already added. --- @@ -955,4 +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` | Forward the request with the placeholder unchanged. The real secret is not substituted. | +| `Passthrough(PassthroughPolicy)` | Forward matching hosts with the placeholder unchanged; use the policy fallback for non-matching hosts. | diff --git a/docs/sdk/rust/secrets.mdx b/docs/sdk/rust/secrets.mdx index 25289f04a..f486935d6 100644 --- a/docs/sdk/rust/secrets.mdx +++ b/docs/sdk/rust/secrets.mdx @@ -60,38 +60,6 @@ Add a wildcard host pattern. The `*` matches any subdomain prefix. --- -#### allow_passthrough_host() - -```rust -fn allow_passthrough_host(self, host: impl Into) -> Self -``` - -Allow this secret's placeholder to pass through unchanged to an exact host. This is useful when a downstream service may receive sandbox transcripts or request bodies containing placeholders, but must not receive the real secret value. It does **not** enable substitution for that host. - -**Parameters** - -| Name | Type | Description | -|------|------|-------------| -| host | `impl Into` | Exact hostname (e.g. `"api.anthropic.com"`) | - ---- - -#### allow_passthrough_host_pattern() - -```rust -fn allow_passthrough_host_pattern(self, pattern: impl Into) -> Self -``` - -Allow this secret's placeholder to pass through unchanged to hosts matching a wildcard pattern. It does **not** enable substitution for those hosts. - -**Parameters** - -| Name | Type | Description | -|------|------|-------------| -| pattern | `impl Into` | Wildcard pattern (e.g. `"*.anthropic.com"`) | - ---- - #### env() ```rust @@ -204,6 +172,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 @@ -244,11 +239,11 @@ Convenience method on `SandboxBuilder`. Equivalent to `.secret(|s| s.env(env_var ### ViolationAction -Configured globally 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 substitution allow list and is not configured as a passthrough host. +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` | Forward the request with the placeholder unchanged. The real secret is not substituted. | +| `Passthrough(PassthroughPolicy)` | Forward matching hosts with the placeholder unchanged; use the policy fallback for non-matching hosts. | diff --git a/docs/sdk/typescript/networking.mdx b/docs/sdk/typescript/networking.mdx index 155cf9336..6109beefb 100644 --- a/docs/sdk/typescript/networking.mdx +++ b/docs/sdk/typescript/networking.mdx @@ -1024,30 +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). - ---- - -#### allowSecretPassthroughHost() +Configure the action taken when a secret reaches a disallowed host: ```typescript -allowSecretPassthroughHost(host: string): this +.network((n) => + n.onSecretViolation((v) => + v.blockAndLog() + .passthroughHost("api.anthropic.com") + ) +) ``` -Allow configured secret placeholders to pass through unchanged to an exact host. This does **not** allow that host to receive real secret values. +Passthrough hosts receive placeholders unchanged. They do **not** receive real secret values. --- -#### allowSecretPassthroughHostPattern() +## ViolationActionBuilder -```typescript -allowSecretPassthroughHostPattern(pattern: string): this -``` - -Allow configured secret placeholders to pass through unchanged to hosts matching a wildcard pattern. This does **not** allow those hosts to receive real secret values. +| Method | Description | +|--------|-------------| +| `block()` | Use `block` as the fallback action | +| `blockAndLog()` | Use `block-and-log` as the fallback action | +| `blockAndTerminate()` | Use `block-and-terminate` as the fallback action | +| `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. Blocking calls update the fallback action and keep any passthrough hosts already added. --- diff --git a/docs/sdk/typescript/secrets.mdx b/docs/sdk/typescript/secrets.mdx index 53f800295..71bc3c6c0 100644 --- a/docs/sdk/typescript/secrets.mdx +++ b/docs/sdk/typescript/secrets.mdx @@ -49,8 +49,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. | -| `allowPassthroughHost(host)` | Allow the placeholder to pass through unchanged to a specific host. Does not enable substitution. | -| `allowPassthroughHostPattern(pattern)` | Allow the placeholder to pass through unchanged to hosts matching a wildcard. Does not enable substitution. | +| `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`. | @@ -98,8 +97,6 @@ The object produced by `SecretBuilder.build()`. You normally construct one with | placeholder | `string \| null` | Custom placeholder; defaults to `$MSB_` when `null` | | allowedHosts | `readonly string[]` | Allowed hosts (exact match) | | allowedHostPatterns | `readonly string[]` | Wildcard host patterns | -| passthroughHosts | `readonly string[]` | Hosts allowed to receive the placeholder unchanged | -| passthroughHostPatterns | `readonly string[]` | Wildcard host patterns allowed to receive the placeholder unchanged | | allowAnyHost | `boolean` | Permit substitution to any host | | requireTlsIdentity | `boolean` | Require verified TLS identity | | injection | [`SecretInjection`](#secretinjection) | Where to substitute | @@ -128,4 +125,4 @@ type ViolationAction = "block" | "block-and-log" | "block-and-terminate" | "pass | `'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 the request with the placeholder unchanged. The real secret is not substituted. | +| `'passthrough'` | Forward matching hosts with the placeholder unchanged; use the configured fallback for non-matching hosts. | diff --git a/sdk/node-ts/native/index.cjs b/sdk/node-ts/native/index.cjs index 9659b63f7..3507deaab 100644 --- a/sdk/node-ts/native/index.cjs +++ b/sdk/node-ts/native/index.cjs @@ -639,6 +639,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 06e4401a2..f54802198 100644 --- a/sdk/node-ts/native/index.d.ts +++ b/sdk/node-ts/native/index.d.ts @@ -415,15 +415,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" | "passthrough"`. - */ - onSecretViolation(action: string): this - /** Allow a host to receive secret placeholders without substitution. */ - allowSecretPassthroughHost(host: string): this - /** Allow hosts matching a wildcard pattern to receive secret placeholders without substitution. */ - allowSecretPassthroughHostPattern(pattern: 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. */ @@ -1087,10 +1080,6 @@ export declare class SecretBuilder { * Pass `true` to opt in. */ allowAnyHostDangerous(iUnderstand: boolean): this - /** Add an exact-match host that may receive the placeholder unchanged. */ - allowPassthroughHost(host: string): this - /** Add a wildcard host pattern that may receive the placeholder unchanged. */ - allowPassthroughHostPattern(pattern: string): this /** Require verified TLS identity before substituting (default: true). */ requireTlsIdentity(enabled: boolean): this /** Configure header injection (default: true). */ @@ -1101,6 +1090,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 @@ -1217,6 +1208,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> @@ -1684,10 +1693,6 @@ export interface SecretEntry { allowedHosts: Array /** Wildcard host patterns (e.g. `*.openai.com`) allowed to receive this secret. */ allowedHostPatterns: Array - /** Exact host names allowed to receive this secret's placeholder unchanged. */ - passthroughHosts: Array - /** Wildcard host patterns allowed to receive this secret's placeholder unchanged. */ - passthroughHostPatterns: Array /** Allow any host. **Dangerous** — secret can be exfiltrated. */ allowAnyHost: boolean /** Require verified TLS identity before substituting (default: true). */ diff --git a/sdk/node-ts/native/lib.rs b/sdk/node-ts/native/lib.rs index 38db8aae3..d9df5f7a5 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 591afef00..6ac834895 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,32 +221,24 @@ impl JsNetworkBuilder { Ok(self) } - /// Set the violation action for secrets: `"block" | "block-and-log" - /// | "block-and-terminate" | "passthrough"`. + /// 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) } - /// Allow a host to receive secret placeholders without substitution. - #[napi(js_name = "allowSecretPassthroughHost")] - pub fn allow_secret_passthrough_host(&mut self, host: String) -> &Self { - let prev = self.take_inner(); - self.inner = Some(prev.allow_secret_passthrough_host(host)); - self - } - - /// Allow hosts matching a wildcard pattern to receive secret placeholders without substitution. - #[napi(js_name = "allowSecretPassthroughHostPattern")] - pub fn allow_secret_passthrough_host_pattern(&mut self, pattern: String) -> &Self { - let prev = self.take_inner(); - self.inner = Some(prev.allow_secret_passthrough_host_pattern(pattern)); - self - } - /// Set the maximum number of concurrent connections. #[napi(js_name = "maxConnections")] pub fn max_connections(&mut self, max: u32) -> &Self { @@ -322,19 +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), - "passthrough" => Ok(RustViolationAction::Passthrough), - other => Err(napi::Error::from_reason(format!( - "unknown violation action `{other}` (expected block | block-and-log | block-and-terminate | passthrough)" - ))), - } -} diff --git a/sdk/node-ts/native/secret_builder.rs b/sdk/node-ts/native/secret_builder.rs index 0e82d8a8d..0898b2542 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 @@ -22,10 +24,6 @@ pub struct JsSecretEntry { pub allowed_hosts: Vec, /// Wildcard host patterns (e.g. `*.openai.com`) allowed to receive this secret. pub allowed_host_patterns: Vec, - /// Exact host names allowed to receive this secret's placeholder unchanged. - pub passthrough_hosts: Vec, - /// Wildcard host patterns allowed to receive this secret's placeholder unchanged. - pub passthrough_host_patterns: Vec, /// Allow any host. **Dangerous** — secret can be exfiltrated. pub allow_any_host: bool, /// Require verified TLS identity before substituting (default: true). @@ -112,22 +110,6 @@ impl JsSecretBuilder { self } - /// Add an exact-match host that may receive the placeholder unchanged. - #[napi(js_name = "allowPassthroughHost")] - pub fn allow_passthrough_host(&mut self, host: String) -> &Self { - let prev = self.take_inner(); - self.inner = Some(prev.allow_passthrough_host(host)); - self - } - - /// Add a wildcard host pattern that may receive the placeholder unchanged. - #[napi(js_name = "allowPassthroughHostPattern")] - pub fn allow_passthrough_host_pattern(&mut self, pattern: String) -> &Self { - let prev = self.take_inner(); - self.inner = Some(prev.allow_passthrough_host_pattern(pattern)); - self - } - /// Require verified TLS identity before substituting (default: true). #[napi(js_name = "requireTlsIdentity")] pub fn require_tls_identity(&mut self, enabled: bool) -> &Self { @@ -168,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). @@ -223,22 +223,13 @@ pub(crate) fn to_js_secret_entry(entry: RustSecretEntry) -> JsSecretEntry { let mut allowed_hosts = Vec::new(); let mut allowed_host_patterns = Vec::new(); let mut allow_any_host = false; - let mut passthrough_hosts = Vec::new(); - let mut passthrough_host_patterns = Vec::new(); 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, - } - } - for h in entry.passthrough_hosts { - match h { - HostPattern::Exact(s) => passthrough_hosts.push(s), - HostPattern::Wildcard(s) => passthrough_host_patterns.push(s), - HostPattern::Any => unreachable!( - "passthrough_hosts cannot contain HostPattern::Any; builder only emits exact/wildcard patterns" - ), + 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 { @@ -247,8 +238,6 @@ pub(crate) fn to_js_secret_entry(entry: RustSecretEntry) -> JsSecretEntry { placeholder: entry.placeholder, allowed_hosts, allowed_host_patterns, - passthrough_hosts, - passthrough_host_patterns, allow_any_host, require_tls_identity: entry.require_tls_identity, injection: JsSecretInjection { 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 000000000..daa654ef1 --- /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 93ccffbd3..79c7d63c8 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; @@ -626,13 +627,14 @@ export interface NapiSecretBuilder { allowHost(host: string): this; allowHostPattern(pattern: string): this; allowAnyHostDangerous(iUnderstand: boolean): this; - allowPassthroughHost(host: string): this; - allowPassthroughHostPattern(pattern: string): this; requireTlsIdentity(enabled: boolean): this; injectHeaders(enabled: boolean): this; injectBasicAuth(enabled: boolean): this; injectQuery(enabled: boolean): this; injectBody(enabled: boolean): this; + onViolation( + configure: (b: NapiViolationActionBuilder) => NapiViolationActionBuilder, + ): this; build(): NapiSecretEntry; } @@ -642,8 +644,6 @@ export interface NapiSecretEntry { readonly placeholder: string; readonly allowedHosts: string[]; readonly allowedHostPatterns: string[]; - readonly passthroughHosts: string[]; - readonly passthroughHostPatterns: string[]; readonly allowAnyHost: boolean; readonly requireTlsIdentity: boolean; readonly injection: NapiSecretInjection; @@ -672,9 +672,9 @@ export interface NapiNetworkBuilder { interface( configure: (b: NapiInterfaceOverridesBuilder) => NapiInterfaceOverridesBuilder, ): this; - onSecretViolation(action: string): this; - allowSecretPassthroughHost(host: string): this; - allowSecretPassthroughHostPattern(pattern: string): this; + onSecretViolation( + configure: (b: NapiViolationActionBuilder) => NapiViolationActionBuilder, + ): this; maxConnections(max: number): this; ipv4Pool(pool: string): this; ipv6Pool(pool: string): this; @@ -688,6 +688,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/network-config.ts b/sdk/node-ts/src/network-config.ts index 7f732993d..9f386a8d4 100644 --- a/sdk/node-ts/src/network-config.ts +++ b/sdk/node-ts/src/network-config.ts @@ -42,8 +42,6 @@ export interface SecretEntry { readonly placeholder: string | null; readonly allowedHosts: readonly string[]; readonly allowedHostPatterns: readonly string[]; - readonly passthroughHosts: readonly string[]; - readonly passthroughHostPatterns: readonly string[]; readonly allowAnyHost: boolean; readonly requireTlsIdentity: boolean; readonly injection: SecretInjection; diff --git a/sdk/node-ts/tests/unit/builders.test.ts b/sdk/node-ts/tests/unit/builders.test.ts index 07e54592c..5dcc7c6d5 100644 --- a/sdk/node-ts/tests/unit/builders.test.ts +++ b/sdk/node-ts/tests/unit/builders.test.ts @@ -355,30 +355,43 @@ describe("NetworkBuilder.secretEnvSimple (3-arg shorthand)", () => { }); describe("NetworkBuilder secret passthrough", () => { - it("builds global passthrough host allowlists", () => { + it("builds global passthrough violation policy", () => { const cfg = new NetworkBuilder() - .allowSecretPassthroughHost("api.anthropic.com") - .allowSecretPassthroughHostPattern("*.anthropic.com") + .onSecretViolation((v) => + v + .blockAndTerminate() + .passthroughHost("api.anthropic.com") + .passthroughHostPattern("*.anthropic.com"), + ) .build() as { secrets: { - passthroughHosts: unknown[]; + onViolation: { + passthrough: { + hosts: unknown[]; + fallback: string; + }; + }; }; }; - expect(cfg.secrets.passthroughHosts).toHaveLength(2); + expect(cfg.secrets.onViolation.passthrough.hosts).toHaveLength(2); + expect(cfg.secrets.onViolation.passthrough.fallback).toBe("BlockAndTerminate"); }); - it("builds per-secret passthrough host allowlists", () => { + it("builds per-secret passthrough violation policy", () => { const secret = new SecretBuilder() .env("API_KEY") .value("sk-abc") .allowHost("api.github.com") - .allowPassthroughHost("api.anthropic.com") - .allowPassthroughHostPattern("*.anthropic.com") + .onViolation((v) => + v + .blockAndLog() + .passthroughHost("api.anthropic.com") + .passthroughHostPattern("*.anthropic.com"), + ) .build(); - expect(secret.passthroughHosts).toEqual(["api.anthropic.com"]); - expect(secret.passthroughHostPatterns).toEqual(["*.anthropic.com"]); + expect(secret.allowedHosts).toEqual(["api.github.com"]); }); }); diff --git a/sdk/python/microsandbox/__init__.py b/sdk/python/microsandbox/__init__.py index 26e4b1ff9..89b54a817 100644 --- a/sdk/python/microsandbox/__init__.py +++ b/sdk/python/microsandbox/__init__.py @@ -115,6 +115,7 @@ Stdin, TlsConfig, ViolationAction, + ViolationPolicy, ) # Pass the bundled msb path to Rust explicitly. `MSB_PATH` remains a user @@ -198,6 +199,7 @@ "SecretInjection", "TlsConfig", "ViolationAction", + "ViolationPolicy", # Images / rootfs "Image", "ImageSource", diff --git a/sdk/python/microsandbox/types.py b/sdk/python/microsandbox/types.py index 08b8f8a38..f9fd095ed 100644 --- a/sdk/python/microsandbox/types.py +++ b/sdk/python/microsandbox/types.py @@ -70,6 +70,59 @@ class ViolationAction(enum.StrEnum): 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, + fallback: ViolationAction = ViolationAction.BLOCK_AND_LOG, + ) -> ViolationPolicy: + return cls( + fallback=fallback, + 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 = {"fallback": str(self.fallback)} + 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" NAMED = "named" @@ -507,10 +560,9 @@ class SecretEntry: value: str allow_hosts: tuple[str, ...] = () allow_host_patterns: tuple[str, ...] = () - passthrough_hosts: tuple[str, ...] = () - passthrough_host_patterns: tuple[str, ...] = () placeholder: str | None = None require_tls: bool = True + on_violation: ViolationAction | ViolationPolicy = ViolationAction.BLOCK_AND_LOG injection: SecretInjection = field(default_factory=SecretInjection) def _to_dict(self) -> dict: @@ -519,14 +571,13 @@ def _to_dict(self) -> dict: d["allow_hosts"] = list(self.allow_hosts) if self.allow_host_patterns: d["allow_host_patterns"] = list(self.allow_host_patterns) - if self.passthrough_hosts: - d["passthrough_hosts"] = list(self.passthrough_hosts) - if self.passthrough_host_patterns: - d["passthrough_host_patterns"] = list(self.passthrough_host_patterns) if self.placeholder is not None: d["placeholder"] = self.placeholder if not self.require_tls: d["require_tls"] = False + 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 @@ -542,10 +593,9 @@ def env( value: str, allow_hosts: Sequence[str] = (), allow_host_patterns: Sequence[str] = (), - passthrough_hosts: Sequence[str] = (), - passthrough_host_patterns: Sequence[str] = (), placeholder: str | None = None, require_tls: bool = True, + on_violation: ViolationAction | ViolationPolicy = ViolationAction.BLOCK_AND_LOG, injection: SecretInjection | None = None, ) -> SecretEntry: return SecretEntry( @@ -553,10 +603,9 @@ def env( value=value, allow_hosts=tuple(allow_hosts), allow_host_patterns=tuple(allow_host_patterns), - passthrough_hosts=tuple(passthrough_hosts), - passthrough_host_patterns=tuple(passthrough_host_patterns), placeholder=placeholder, require_tls=require_tls, + on_violation=on_violation, injection=injection if injection is not None else SecretInjection(), ) @@ -733,9 +782,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 = ViolationAction.BLOCK_AND_LOG - secret_passthrough_hosts: tuple[str, ...] = () - secret_passthrough_host_patterns: tuple[str, ...] = () + on_secret_violation: ViolationAction | ViolationPolicy = ViolationAction.BLOCK_AND_LOG @classmethod def none(cls) -> Network: @@ -777,14 +824,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 - if self.on_secret_violation != ViolationAction.BLOCK_AND_LOG: - d["on_secret_violation"] = str(self.on_secret_violation) - if self.secret_passthrough_hosts: - d["secret_passthrough_hosts"] = list(self.secret_passthrough_hosts) - if self.secret_passthrough_host_patterns: - d["secret_passthrough_host_patterns"] = list(self.secret_passthrough_host_patterns) + 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 b83695203..2bff104be 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,25 +833,14 @@ 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)); - } - - // Secret passthrough hosts (sandbox-level). - let secret_passthrough_hosts: Vec = - extract_opt(net, "secret_passthrough_hosts")?.unwrap_or_default(); - let secret_passthrough_host_patterns: Vec = - extract_opt(net, "secret_passthrough_host_patterns")?.unwrap_or_default(); - if !secret_passthrough_hosts.is_empty() || !secret_passthrough_host_patterns.is_empty() { - builder = builder.network(|mut n| { - for host in &secret_passthrough_hosts { - n = n.allow_secret_passthrough_host(host); - } - for pattern in &secret_passthrough_host_patterns { - n = n.allow_secret_passthrough_host_pattern(pattern); - } - n + 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) + }) }); } @@ -952,10 +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 passthrough_hosts: Vec = - extract_opt(secret, "passthrough_hosts")?.unwrap_or_default(); - let passthrough_host_patterns: Vec = - extract_opt(secret, "passthrough_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")?; @@ -981,11 +979,10 @@ fn apply_secret( for pattern in &allow_host_patterns { s = s.allow_host_pattern(pattern); } - for host in &passthrough_hosts { - s = s.allow_passthrough_host(host); - } - for pattern in &passthrough_host_patterns { - s = s.allow_passthrough_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); @@ -1038,18 +1035,76 @@ 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, PassthroughPolicy, 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), + "passthrough" => Ok(ViolationAction::Passthrough(PassthroughPolicy { + hosts: vec![HostPattern::Any], + fallback: Box::new(ViolationAction::BlockAndLog), + })), _ => 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, PassthroughPolicy, ViolationAction}; + + let fallback = extract_opt::(dict, "fallback")? + .map(|s| parse_violation_action(&s)) + .transpose()? + .unwrap_or(ViolationAction::BlockAndLog); + if matches!(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(PassthroughPolicy { + hosts: patterns, + fallback: Box::new(fallback), + })) +} + 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 index 2fc61aa12..691bf51aa 100644 --- a/sdk/python/tests/test_secret_passthrough.py +++ b/sdk/python/tests/test_secret_passthrough.py @@ -2,7 +2,7 @@ from __future__ import annotations -from microsandbox import Network, Secret, ViolationAction +from microsandbox import Network, Secret, ViolationAction, ViolationPolicy def test_violation_action_includes_passthrough() -> None: @@ -14,28 +14,37 @@ def test_secret_passthrough_hosts_serialize() -> None: "API_KEY", value="sk-abc", allow_hosts=("api.github.com",), - passthrough_hosts=("api.anthropic.com",), - passthrough_host_patterns=("*.anthropic.com",), + on_violation=ViolationPolicy.passthrough( + hosts=("api.anthropic.com",), + host_patterns=("*.anthropic.com",), + fallback=ViolationAction.BLOCK_AND_TERMINATE, + ), ) assert secret._to_dict() == { "env_var": "API_KEY", "value": "sk-abc", "allow_hosts": ["api.github.com"], - "passthrough_hosts": ["api.anthropic.com"], - "passthrough_host_patterns": ["*.anthropic.com"], + "on_violation": { + "passthrough": { + "fallback": "block-and-terminate", + "hosts": ["api.anthropic.com"], + "host_patterns": ["*.anthropic.com"], + } + }, } def test_network_secret_passthrough_hosts_serialize() -> None: network = Network( - on_secret_violation=ViolationAction.PASSTHROUGH, - secret_passthrough_hosts=("api.anthropic.com",), - secret_passthrough_host_patterns=("*.anthropic.com",), + on_secret_violation=ViolationPolicy.passthrough(all_hosts=True), ) assert network._to_dict() == { - "on_secret_violation": "passthrough", - "secret_passthrough_hosts": ["api.anthropic.com"], - "secret_passthrough_host_patterns": ["*.anthropic.com"], + "on_secret_violation": { + "passthrough": { + "fallback": "block-and-log", + "all_hosts": True, + } + }, } From 8eecfd00bede3754fcc585665d8ec18768b65e82 Mon Sep 17 00:00:00 2001 From: Tochukwu Nkemdilim <11903253+toksdotdev@users.noreply.github.com> Date: Thu, 21 May 2026 21:23:57 -0400 Subject: [PATCH 3/6] chore(secrets): fallback to default violation action during substitution --- crates/cli/lib/commands/common.rs | 7 +- crates/network/lib/builder.rs | 113 +++---- crates/network/lib/secrets/config.rs | 21 +- crates/network/lib/secrets/handler.rs | 330 +++++++++++--------- crates/network/lib/tls/proxy.rs | 28 +- docs/sdk/go/secrets.mdx | 11 + docs/sdk/python/secrets.mdx | 18 +- docs/sdk/rust/networking.mdx | 12 +- docs/sdk/rust/secrets.mdx | 14 +- docs/sdk/typescript/networking.mdx | 8 +- docs/sdk/typescript/secrets.mdx | 16 +- sdk/go/native/src/lib.rs | 19 +- sdk/node-ts/tests/unit/builders.test.ts | 8 +- sdk/python/microsandbox/types.py | 4 +- sdk/python/src/helpers.rs | 25 +- sdk/python/tests/test_secret_passthrough.py | 3 - 16 files changed, 339 insertions(+), 298 deletions(-) diff --git a/crates/cli/lib/commands/common.rs b/crates/cli/lib/commands/common.rs index fa24b73e7..7c81d3bc1 100644 --- a/crates/cli/lib/commands/common.rs +++ b/crates/cli/lib/commands/common.rs @@ -963,16 +963,13 @@ fn parse_secret(spec: &str) -> anyhow::Result<(String, String, String)> { fn parse_violation_action( s: &Option, ) -> anyhow::Result> { - use microsandbox_network::secrets::config::{HostPattern, PassthroughPolicy, 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(PassthroughPolicy { - hosts: vec![HostPattern::Any], - fallback: Box::new(ViolationAction::BlockAndLog), - }))), + 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, passthrough)" ), diff --git a/crates/network/lib/builder.rs b/crates/network/lib/builder.rs index ffa138fab..69410b693 100644 --- a/crates/network/lib/builder.rs +++ b/crates/network/lib/builder.rs @@ -10,9 +10,7 @@ use ipnetwork::{Ipv4Network, Ipv6Network}; use crate::config::{DnsConfig, InterfaceOverrides, NetworkConfig, PortProtocol, PublishedPort}; use crate::dns::Nameserver; use crate::policy::{BuildError, NetworkPolicy}; -use crate::secrets::config::{ - HostPattern, PassthroughPolicy, SecretEntry, SecretInjection, ViolationAction, -}; +use crate::secrets::config::{HostPattern, SecretEntry, SecretInjection, ViolationAction}; use crate::tls::TlsConfig; //-------------------------------------------------------------------------------------------------- @@ -51,14 +49,14 @@ pub struct SecretBuilder { placeholder: Option, allowed_hosts: Vec, injection: SecretInjection, - on_violation: ViolationAction, + on_violation: Option, require_tls_identity: bool, } /// Fluent builder for a [`ViolationAction`]. +#[derive(Default)] pub struct ViolationActionBuilder { - fallback: ViolationAction, - passthrough_hosts: Vec, + action: ViolationAction, } //-------------------------------------------------------------------------------------------------- @@ -188,7 +186,7 @@ impl NetworkBuilder { placeholder: placeholder.into(), allowed_hosts: vec![HostPattern::Exact(allowed_host.into())], injection: SecretInjection::default(), - on_violation: ViolationAction::default(), + on_violation: None, require_tls_identity: true, }); self @@ -199,7 +197,7 @@ impl NetworkBuilder { mut self, f: impl FnOnce(ViolationActionBuilder) -> ViolationActionBuilder, ) -> Self { - self.config.secrets.on_violation = f(ViolationActionBuilder::new()).build(); + self.config.secrets.on_violation = f(ViolationActionBuilder::default()).build(); self } @@ -384,7 +382,7 @@ impl SecretBuilder { placeholder: None, allowed_hosts: Vec::new(), injection: SecretInjection::default(), - on_violation: ViolationAction::default(), + on_violation: None, require_tls_identity: true, } } @@ -435,7 +433,7 @@ impl SecretBuilder { mut self, f: impl FnOnce(ViolationActionBuilder) -> ViolationActionBuilder, ) -> Self { - self.on_violation = f(ViolationActionBuilder::new()).build(); + self.on_violation = Some(f(ViolationActionBuilder::default()).build()); self } @@ -495,41 +493,29 @@ impl SecretBuilder { impl ViolationActionBuilder { /// Start building a violation action. pub fn new() -> Self { - Self { - fallback: ViolationAction::default(), - passthrough_hosts: Vec::new(), - } + Self::default() } /// Start building from an existing action. pub fn from_action(action: ViolationAction) -> Self { - match action { - ViolationAction::Passthrough(policy) => Self { - fallback: *policy.fallback, - passthrough_hosts: policy.hosts, - }, - action => Self { - fallback: action, - passthrough_hosts: Vec::new(), - }, - } + action.into() } /// Block the request silently. pub fn block(mut self) -> Self { - self.fallback = ViolationAction::Block; + self.action = ViolationAction::Block; self } /// Block the request and emit a warning log. pub fn block_and_log(mut self) -> Self { - self.fallback = ViolationAction::BlockAndLog; + self.action = ViolationAction::BlockAndLog; self } /// Block the request and terminate the sandbox. pub fn block_and_terminate(mut self) -> Self { - self.fallback = ViolationAction::BlockAndTerminate; + self.action = ViolationAction::BlockAndTerminate; self } @@ -553,20 +539,17 @@ impl ViolationActionBuilder { self } - /// Consume the builder and return the action. - pub fn build(self) -> ViolationAction { - if self.passthrough_hosts.is_empty() { - return self.fallback; + /// 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]), } - - ViolationAction::Passthrough(PassthroughPolicy { - hosts: self.passthrough_hosts, - fallback: Box::new(self.fallback), - }) } - fn push_passthrough_host(&mut self, host: HostPattern) { - self.passthrough_hosts.push(host); + /// Consume the builder and return the action. + pub fn build(self) -> ViolationAction { + self.action } } @@ -591,10 +574,9 @@ impl Default for SecretBuilder { Self::new() } } - -impl Default for ViolationActionBuilder { - fn default() -> Self { - Self::new() +impl From for ViolationActionBuilder { + fn from(action: ViolationAction) -> Self { + Self { action } } } @@ -645,13 +627,10 @@ mod tests { assert_eq!( cfg.secrets.on_violation, - ViolationAction::Passthrough(PassthroughPolicy { - hosts: vec![ - HostPattern::Exact("api.anthropic.com".into()), - HostPattern::Wildcard("*.anthropic.com".into()), - ], - fallback: Box::new(ViolationAction::BlockAndLog), - }) + ViolationAction::Passthrough(vec![ + HostPattern::Exact("api.anthropic.com".into()), + HostPattern::Wildcard("*.anthropic.com".into()), + ]) ); } @@ -669,19 +648,16 @@ mod tests { assert_eq!( secret.on_violation, - ViolationAction::Passthrough(PassthroughPolicy { - hosts: vec![ - HostPattern::Exact("api.anthropic.com".into()), - HostPattern::Wildcard("*.anthropic.com".into()), - ], - fallback: Box::new(ViolationAction::BlockAndLog), - }) + Some(ViolationAction::Passthrough(vec![ + HostPattern::Exact("api.anthropic.com".into()), + HostPattern::Wildcard("*.anthropic.com".into()), + ])), ); } #[test] - fn violation_action_builder_preserves_fallback_action() { - let action = ViolationActionBuilder::new() + fn violation_action_builder_blocking_call_replaces_passthrough_policy() { + let action = ViolationActionBuilder::default() .passthrough_host("google.com") .block_and_terminate() .passthrough_host("facebook.com") @@ -689,19 +665,13 @@ mod tests { assert_eq!( action, - ViolationAction::Passthrough(PassthroughPolicy { - hosts: vec![ - HostPattern::Exact("google.com".into()), - HostPattern::Exact("facebook.com".into()), - ], - fallback: Box::new(ViolationAction::BlockAndTerminate), - }) + ViolationAction::Passthrough(vec![HostPattern::Exact("facebook.com".into())]) ); } #[test] fn violation_action_builder_accumulates_passthrough_hosts() { - let action = ViolationActionBuilder::new() + let action = ViolationActionBuilder::default() .block() .passthrough_host("google.com") .passthrough_host("facebook.com") @@ -709,13 +679,10 @@ mod tests { assert_eq!( action, - ViolationAction::Passthrough(PassthroughPolicy { - hosts: vec![ - HostPattern::Exact("google.com".into()), - HostPattern::Exact("facebook.com".into()), - ], - fallback: Box::new(ViolationAction::Block), - }) + 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 08571a9fd..31856f940 100644 --- a/crates/network/lib/secrets/config.rs +++ b/crates/network/lib/secrets/config.rs @@ -39,8 +39,8 @@ pub struct SecretEntry { pub injection: SecretInjection, /// Action on secret violation for this secret. - #[serde(default)] - pub on_violation: ViolationAction, + #[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 @@ -91,20 +91,7 @@ pub enum ViolationAction { /// Block and terminate the sandbox. BlockAndTerminate, /// Forward the request with the placeholder unchanged for matching hosts. - #[serde(rename = "passthrough")] - Passthrough(PassthroughPolicy), -} - -/// Hosts that may receive placeholders unchanged, plus the fallback action for -/// non-matching hosts. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PassthroughPolicy { - /// Hosts allowed to receive placeholders unchanged. - #[serde(default)] - pub hosts: Vec, - /// Action for hosts that do not match `hosts`. - #[serde(default)] - pub fallback: Box, + Passthrough(Vec), } //-------------------------------------------------------------------------------------------------- @@ -219,7 +206,7 @@ mod tests { placeholder: "$K".into(), allowed_hosts: vec![], injection: SecretInjection::default(), - on_violation: ViolationAction::default(), + on_violation: None, require_tls_identity: true, }; assert!(entry.require_tls_identity); diff --git a/crates/network/lib/secrets/handler.rs b/crates/network/lib/secrets/handler.rs index 29b1f1d73..490013a19 100644 --- a/crates/network/lib/secrets/handler.rs +++ b/crates/network/lib/secrets/handler.rs @@ -20,15 +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, - /// Secret placeholders that should use a per-secret fallback action. - ineligible: Vec, - /// All placeholder strings (for violation detection on disallowed hosts). - all_placeholders: Vec, - /// Global violation action. - on_violation: ViolationAction, - /// Whether any disallowed placeholders 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. @@ -53,7 +47,16 @@ struct EligibleSecret { /// A secret that did not pass substitution or passthrough host matching. struct IneligibleSecret { placeholder: String, - fallback: ViolationAction, + 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, } //-------------------------------------------------------------------------------------------------- @@ -121,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. /// @@ -129,19 +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 ineligible = Vec::new(); - let mut all_placeholders = Vec::new(); - let global_decision = violation_decision(&config.on_violation, sni); + 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, @@ -150,26 +173,29 @@ impl SecretsHandler { inject_body: secret.injection.body, require_tls_identity: secret.require_tls_identity, }); - } else { - let secret_decision = violation_decision(&secret.on_violation, sni); - if !(secret_decision.passthrough || global_decision.passthrough) { - ineligible.push(IneligibleSecret { - placeholder: secret.placeholder.clone(), - fallback: secret_decision.fallback, - }); + + continue; + } + + 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 { + if hosts.iter().any(|p| p.matches(sni)) { + continue; } } - } - let has_ineligible = !ineligible.is_empty(); - let max_placeholder_len = all_placeholders.iter().map(String::len).max().unwrap_or(0); + // 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, - ineligible, - all_placeholders, - on_violation: global_decision.fallback, - has_ineligible, + eligible_for_substitution, + ineligible_for_substitution, tls_intercepted, max_placeholder_len, prev_tail: Vec::new(), @@ -184,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); @@ -201,38 +227,30 @@ impl SecretsHandler { String::new() }; - // Fast path: skip violation check when no ineligible secrets exist. - if let Some(action) = self.detect_violation_action(data, &header_str) { + // Check for disallowed placeholders before forwarding or substituting data. + if let Some(action) = self.detect_blocking_action(data, &header_str) { match action { - ViolationAction::Block => { - self.update_tail(data); - return None; - } - ViolationAction::BlockAndLog => { - self.update_tail(data); + 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 => { - self.update_tail(data); + BlockingAction::BlockAndTerminate => { tracing::error!( "secret violation: placeholder detected for disallowed host — terminating" ); - return None; - } - ViolationAction::Passthrough(policy) => { - debug_assert!(policy.hosts.is_empty()); + 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; @@ -252,28 +270,23 @@ 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() } - /// Returns the strictest action for any placeholder appearing in data for a - /// host that isn't allowed to receive either the real secret or the placeholder. + /// 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 detect_violation_action(&self, data: &[u8], headers: &str) -> Option { - if !self.has_ineligible { + fn detect_blocking_action(&self, data: &[u8], headers: &str) -> Option { + if self.ineligible_for_substitution.is_empty() { return None; } @@ -286,22 +299,16 @@ impl SecretsHandler { Cow::Owned(stitched) }; let scan = scan_buf.as_ref(); - let mut detected = None; - for secret in &self.ineligible { + 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, &secret.placeholder) { - detected = Some(strictest_violation_action( - detected, - secret.fallback.clone(), - )); - if detected == Some(ViolationAction::BlockAndTerminate) { - break; - } + detected = Some(strictest_violation_action(detected, secret.action)); } } @@ -452,38 +459,20 @@ fn find_header_boundary(data: &[u8]) -> Option { .map(|pos| pos + 4) } -struct ViolationDecision { - passthrough: bool, - fallback: ViolationAction, -} - -fn violation_decision(action: &ViolationAction, sni: &str) -> ViolationDecision { - match action { - ViolationAction::Passthrough(policy) => ViolationDecision { - passthrough: policy.hosts.iter().any(|p| p.matches(sni)), - fallback: (*policy.fallback).clone(), - }, - action => ViolationDecision { - passthrough: false, - fallback: action.clone(), - }, - } -} - +/// Returns the stricter of two blocking actions, where +/// `BlockAndTerminate` > `BlockAndLog` > `Block`. fn strictest_violation_action( - current: Option, - candidate: ViolationAction, -) -> ViolationAction { + current: Option, + candidate: BlockingAction, +) -> BlockingAction { match (current, candidate) { - (Some(ViolationAction::BlockAndTerminate), _) | (_, ViolationAction::BlockAndTerminate) => { - ViolationAction::BlockAndTerminate + (Some(BlockingAction::BlockAndTerminate), _) | (_, BlockingAction::BlockAndTerminate) => { + BlockingAction::BlockAndTerminate } - (Some(ViolationAction::BlockAndLog), _) | (_, ViolationAction::BlockAndLog) => { - ViolationAction::BlockAndLog + (Some(BlockingAction::BlockAndLog), _) | (_, BlockingAction::BlockAndLog) => { + BlockingAction::BlockAndLog } - (Some(ViolationAction::Block), _) | (_, ViolationAction::Block) => ViolationAction::Block, - (Some(ViolationAction::Passthrough(_)), ViolationAction::Passthrough(policy)) - | (None, ViolationAction::Passthrough(policy)) => ViolationAction::Passthrough(policy), + (Some(BlockingAction::Block), _) | (None, BlockingAction::Block) => BlockingAction::Block, } } @@ -510,7 +499,7 @@ mod tests { placeholder: placeholder.into(), allowed_hosts: vec![HostPattern::Exact(host.into())], injection: SecretInjection::default(), - on_violation: ViolationAction::default(), + on_violation: None, require_tls_identity: true, } } @@ -543,31 +532,46 @@ 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(PassthroughPolicy { - hosts: vec![HostPattern::Exact("api.anthropic.com".into())], - fallback: Box::new(ViolationAction::Block), - }); + 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); - assert!(!handler.terminates_on_violation()); } #[test] fn per_secret_passthrough_host_forwards_placeholder_unchanged() { let mut secret = make_secret("$KEY", "real-secret", "api.openai.com"); - secret.on_violation = ViolationAction::Passthrough(PassthroughPolicy { - hosts: vec![HostPattern::Exact("api.anthropic.com".into())], - fallback: Box::new(ViolationAction::Block), - }); + 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); @@ -579,10 +583,7 @@ mod tests { #[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(PassthroughPolicy { - hosts: vec![HostPattern::Any], - fallback: Box::new(ViolationAction::Block), - }); + 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"; @@ -590,48 +591,60 @@ mod tests { 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 = ViolationAction::Passthrough(PassthroughPolicy { - hosts: vec![HostPattern::Exact("api.anthropic.com".into())], - fallback: Box::new(ViolationAction::Block), - }); + 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!(handler.substitute(input).is_none()); + assert_eq!( + handler.substitute(input).unwrap_err(), + ViolationAction::Block + ); } #[test] - fn passthrough_uses_secret_fallback_for_non_matching_host() { + fn per_secret_passthrough_blocks_for_non_matching_host() { let mut secret = make_secret("$KEY", "real-secret", "api.openai.com"); - secret.on_violation = ViolationAction::Passthrough(PassthroughPolicy { - hosts: vec![HostPattern::Exact("api.anthropic.com".into())], - fallback: Box::new(ViolationAction::BlockAndTerminate), - }); + 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!(handler.substitute(input).is_none()); - assert!(!handler.terminates_on_violation()); + assert_eq!( + handler.substitute(input).unwrap_err(), + ViolationAction::BlockAndLog + ); } #[test] - fn passthrough_uses_global_fallback_for_non_matching_host() { + 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(PassthroughPolicy { - hosts: vec![HostPattern::Exact("api.anthropic.com".into())], - fallback: Box::new(ViolationAction::BlockAndTerminate), - }); + 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!(handler.substitute(input).is_none()); - assert!(handler.terminates_on_violation()); + assert_eq!( + handler.substitute(input).unwrap_err(), + ViolationAction::BlockAndLog + ); } #[test] @@ -641,8 +654,24 @@ 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!(handler.terminates_on_violation()); + 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] @@ -799,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] @@ -848,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] @@ -857,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] @@ -868,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] @@ -881,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 17ec350b7..3e20b98c6 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/sdk/go/secrets.mdx b/docs/sdk/go/secrets.mdx index dac2eb651..a36807433 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/secrets.mdx b/docs/sdk/python/secrets.mdx index 56774ffdf..b742e0bae 100644 --- a/docs/sdk/python/secrets.mdx +++ b/docs/sdk/python/secrets.mdx @@ -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 @@ -89,7 +103,7 @@ 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; use the configured fallback for non-matching hosts. | +| `"passthrough"` | Forward matching hosts with the placeholder unchanged. Non-matching hosts use the default secret violation action. | ### ViolationPolicy @@ -99,6 +113,6 @@ Frozen dataclass for passthrough host policies. Use `ViolationPolicy.passthrough ViolationPolicy.passthrough( hosts=("api.anthropic.com",), host_patterns=("*.example.com",), - fallback=ViolationAction.BLOCK_AND_LOG, + all_hosts=False, ) ``` diff --git a/docs/sdk/rust/networking.mdx b/docs/sdk/rust/networking.mdx index ce740fc06..8c77de156 100644 --- a/docs/sdk/rust/networking.mdx +++ b/docs/sdk/rust/networking.mdx @@ -644,7 +644,7 @@ fn on_secret_violation( ) -> Self ``` -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 fallback and optional passthrough hosts: +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| { @@ -664,14 +664,14 @@ Builder for secret violation behavior. Used by [`NetworkBuilder::on_secret_viola | Method | Description | |--------|-------------| -| `block()` | Use `Block` as the fallback action | -| `block_and_log()` | Use `BlockAndLog` as the fallback action | -| `block_and_terminate()` | Use `BlockAndTerminate` as the fallback action | +| `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. Blocking calls update the fallback action and keep any passthrough hosts already added. +Passthrough host calls accumulate. When passthrough hosts are configured, non-matching hosts use the default secret violation action. --- @@ -965,4 +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(PassthroughPolicy)` | Forward matching hosts with the placeholder unchanged; use the policy fallback for non-matching hosts. | +| `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 f486935d6..71defedd5 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. @@ -246,4 +258,4 @@ Configured globally via [`NetworkBuilder::on_secret_violation()`](/sdk/rust/netw | `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(PassthroughPolicy)` | Forward matching hosts with the placeholder unchanged; use the policy fallback for non-matching hosts. | +| `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 6109beefb..4b78c9739 100644 --- a/docs/sdk/typescript/networking.mdx +++ b/docs/sdk/typescript/networking.mdx @@ -1046,14 +1046,14 @@ Passthrough hosts receive placeholders unchanged. They do **not** receive real s | Method | Description | |--------|-------------| -| `block()` | Use `block` as the fallback action | -| `blockAndLog()` | Use `block-and-log` as the fallback action | -| `blockAndTerminate()` | Use `block-and-terminate` as the fallback action | +| `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. Blocking calls update the fallback action and keep any passthrough hosts already added. +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 71bc3c6c0..ed6f49b52 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. @@ -125,4 +139,4 @@ type ViolationAction = "block" | "block-and-log" | "block-and-terminate" | "pass | `'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; use the configured fallback for non-matching hosts. | +| `'passthrough'` | Forward matching hosts with the placeholder unchanged. Non-matching hosts use the default secret violation action. | diff --git a/sdk/go/native/src/lib.rs b/sdk/go/native/src/lib.rs index d11b2c8dd..cf405856b 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/tests/unit/builders.test.ts b/sdk/node-ts/tests/unit/builders.test.ts index 5dcc7c6d5..96c297be0 100644 --- a/sdk/node-ts/tests/unit/builders.test.ts +++ b/sdk/node-ts/tests/unit/builders.test.ts @@ -366,16 +366,12 @@ describe("NetworkBuilder secret passthrough", () => { .build() as { secrets: { onViolation: { - passthrough: { - hosts: unknown[]; - fallback: string; - }; + passthrough: unknown[]; }; }; }; - expect(cfg.secrets.onViolation.passthrough.hosts).toHaveLength(2); - expect(cfg.secrets.onViolation.passthrough.fallback).toBe("BlockAndTerminate"); + expect(cfg.secrets.onViolation.passthrough).toHaveLength(2); }); it("builds per-secret passthrough violation policy", () => { diff --git a/sdk/python/microsandbox/types.py b/sdk/python/microsandbox/types.py index f9fd095ed..7c95a5d02 100644 --- a/sdk/python/microsandbox/types.py +++ b/sdk/python/microsandbox/types.py @@ -97,10 +97,8 @@ def passthrough( hosts: Sequence[str] = (), host_patterns: Sequence[str] = (), all_hosts: bool = False, - fallback: ViolationAction = ViolationAction.BLOCK_AND_LOG, ) -> ViolationPolicy: return cls( - fallback=fallback, passthrough_hosts=tuple(hosts), passthrough_host_patterns=tuple(host_patterns), passthrough_all_hosts=all_hosts, @@ -114,7 +112,7 @@ def _to_dict(self) -> str | dict: ): return str(self.fallback) - passthrough: dict = {"fallback": str(self.fallback)} + passthrough: dict = {} if self.passthrough_hosts: passthrough["hosts"] = list(self.passthrough_hosts) if self.passthrough_host_patterns: diff --git a/sdk/python/src/helpers.rs b/sdk/python/src/helpers.rs index 2bff104be..6e140a6ca 100644 --- a/sdk/python/src/helpers.rs +++ b/sdk/python/src/helpers.rs @@ -1035,15 +1035,12 @@ fn as_dict<'py>(obj: &Bound<'py, PyAny>) -> PyResult> { fn parse_violation_action( s: &str, ) -> PyResult { - use microsandbox_network::secrets::config::{HostPattern, PassthroughPolicy, 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(PassthroughPolicy { - hosts: vec![HostPattern::Any], - fallback: Box::new(ViolationAction::BlockAndLog), - })), + "passthrough" => Ok(ViolationAction::Passthrough(vec![HostPattern::Any])), _ => Err(pyo3::exceptions::PyValueError::new_err(format!( "unknown violation action: {s}" ))), @@ -1072,13 +1069,14 @@ fn parse_violation_action_obj( fn parse_passthrough_policy( dict: &Bound<'_, PyDict>, ) -> PyResult { - use microsandbox_network::secrets::config::{HostPattern, PassthroughPolicy, ViolationAction}; + use microsandbox_network::secrets::config::{HostPattern, ViolationAction}; - let fallback = extract_opt::(dict, "fallback")? - .map(|s| parse_violation_action(&s)) - .transpose()? - .unwrap_or(ViolationAction::BlockAndLog); - if matches!(fallback, ViolationAction::Passthrough(_)) { + 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", )); @@ -1099,10 +1097,7 @@ fn parse_passthrough_policy( patterns.push(HostPattern::Any); } - Ok(ViolationAction::Passthrough(PassthroughPolicy { - hosts: patterns, - fallback: Box::new(fallback), - })) + Ok(ViolationAction::Passthrough(patterns)) } fn extract_opt<'py, T: FromPyObject<'py>>( diff --git a/sdk/python/tests/test_secret_passthrough.py b/sdk/python/tests/test_secret_passthrough.py index 691bf51aa..6ba1855f2 100644 --- a/sdk/python/tests/test_secret_passthrough.py +++ b/sdk/python/tests/test_secret_passthrough.py @@ -17,7 +17,6 @@ def test_secret_passthrough_hosts_serialize() -> None: on_violation=ViolationPolicy.passthrough( hosts=("api.anthropic.com",), host_patterns=("*.anthropic.com",), - fallback=ViolationAction.BLOCK_AND_TERMINATE, ), ) @@ -27,7 +26,6 @@ def test_secret_passthrough_hosts_serialize() -> None: "allow_hosts": ["api.github.com"], "on_violation": { "passthrough": { - "fallback": "block-and-terminate", "hosts": ["api.anthropic.com"], "host_patterns": ["*.anthropic.com"], } @@ -43,7 +41,6 @@ def test_network_secret_passthrough_hosts_serialize() -> None: assert network._to_dict() == { "on_secret_violation": { "passthrough": { - "fallback": "block-and-log", "all_hosts": True, } }, From a6afd70d1cbc6f88375a8958ca8bb67f443e825c Mon Sep 17 00:00:00 2001 From: Tochukwu Nkemdilim <11903253+toksdotdev@users.noreply.github.com> Date: Fri, 22 May 2026 06:44:06 -0400 Subject: [PATCH 4/6] fix(secrets): satisfy clippy for passthrough host filtering --- crates/network/lib/secrets/handler.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/network/lib/secrets/handler.rs b/crates/network/lib/secrets/handler.rs index 490013a19..be7fb1b2b 100644 --- a/crates/network/lib/secrets/handler.rs +++ b/crates/network/lib/secrets/handler.rs @@ -180,10 +180,10 @@ impl SecretsHandler { 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 { - if hosts.iter().any(|p| p.matches(sni)) { - continue; - } + 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. From dae12e5229017a76a52005eb3e0f65915ba749aa Mon Sep 17 00:00:00 2001 From: Tochukwu Nkemdilim <11903253+toksdotdev@users.noreply.github.com> Date: Fri, 22 May 2026 07:17:27 -0400 Subject: [PATCH 5/6] test(node): align passthrough policy assertion with enum shape --- sdk/node-ts/tests/unit/builders.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/sdk/node-ts/tests/unit/builders.test.ts b/sdk/node-ts/tests/unit/builders.test.ts index 96c297be0..b45a790d6 100644 --- a/sdk/node-ts/tests/unit/builders.test.ts +++ b/sdk/node-ts/tests/unit/builders.test.ts @@ -371,7 +371,12 @@ describe("NetworkBuilder secret passthrough", () => { }; }; - expect(cfg.secrets.onViolation.passthrough).toHaveLength(2); + expect(cfg.secrets.onViolation).toEqual({ + Passthrough: [ + { Exact: "api.anthropic.com" }, + { Wildcard: "*.anthropic.com" }, + ], + }); }); it("builds per-secret passthrough violation policy", () => { From c4fed1957b4d8bb52fb58b32a695e893f5b74573 Mon Sep 17 00:00:00 2001 From: Tochukwu Nkemdilim <11903253+toksdotdev@users.noreply.github.com> Date: Fri, 22 May 2026 07:52:59 -0400 Subject: [PATCH 6/6] fix(secrets): serialize violation policies with sdk casing --- crates/network/lib/secrets/config.rs | 46 +++++++++++++++++++++++++ sdk/node-ts/tests/unit/builders.test.ts | 6 ++-- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/crates/network/lib/secrets/config.rs b/crates/network/lib/secrets/config.rs index 31856f940..73eccb44d 100644 --- a/crates/network/lib/secrets/config.rs +++ b/crates/network/lib/secrets/config.rs @@ -51,12 +51,16 @@ pub struct SecretEntry { /// Host pattern for secret allowlist. #[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, } @@ -82,15 +86,20 @@ pub struct SecretInjection { /// Action when a secret placeholder is detected going to a disallowed host. #[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), } @@ -211,4 +220,41 @@ mod tests { }; 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/sdk/node-ts/tests/unit/builders.test.ts b/sdk/node-ts/tests/unit/builders.test.ts index b45a790d6..af5d488b4 100644 --- a/sdk/node-ts/tests/unit/builders.test.ts +++ b/sdk/node-ts/tests/unit/builders.test.ts @@ -372,9 +372,9 @@ describe("NetworkBuilder secret passthrough", () => { }; expect(cfg.secrets.onViolation).toEqual({ - Passthrough: [ - { Exact: "api.anthropic.com" }, - { Wildcard: "*.anthropic.com" }, + passthrough: [ + { exact: "api.anthropic.com" }, + { wildcard: "*.anthropic.com" }, ], }); });