Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions crates/cli/lib/commands/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ pub struct SandboxOpts {
#[arg(long)]
pub secret: Vec<String>,

/// 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<String>,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -961,14 +963,15 @@ fn parse_secret(spec: &str) -> anyhow::Result<(String, String, String)> {
fn parse_violation_action(
s: &Option<String>,
) -> anyhow::Result<Option<microsandbox_network::secrets::config::ViolationAction>> {
use microsandbox_network::secrets::config::ViolationAction;
use microsandbox_network::secrets::config::{HostPattern, ViolationAction};
match s.as_deref() {
None => Ok(None),
Some("block") => Ok(Some(ViolationAction::Block)),
Some("block-and-log") => Ok(Some(ViolationAction::BlockAndLog)),
Some("block-and-terminate") => Ok(Some(ViolationAction::BlockAndTerminate)),
Some("passthrough") => Ok(Some(ViolationAction::Passthrough(vec![HostPattern::Any]))),
Some(other) => anyhow::bail!(
"invalid violation action: {other} (expected: block, block-and-log, block-and-terminate)"
"invalid violation action: {other} (expected: block, block-and-log, block-and-terminate, passthrough)"
),
}
}
Expand Down Expand Up @@ -1253,6 +1256,19 @@ mod tests {

use super::*;

#[cfg(feature = "net")]
#[test]
fn parse_violation_action_accepts_passthrough() {
let action = parse_violation_action(&Some("passthrough".to_string()))
.expect("passthrough should parse")
.expect("action should be present");

assert!(matches!(
action,
microsandbox_network::secrets::config::ViolationAction::Passthrough(_)
));
}

//----------------------------------------------------------------------------------------------
// Tests: apply_volume / -v parser
//----------------------------------------------------------------------------------------------
Expand Down
165 changes: 163 additions & 2 deletions crates/network/lib/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,16 @@ pub struct SecretBuilder {
placeholder: Option<String>,
allowed_hosts: Vec<HostPattern>,
injection: SecretInjection,
on_violation: Option<ViolationAction>,
require_tls_identity: bool,
}

/// Fluent builder for a [`ViolationAction`].
#[derive(Default)]
pub struct ViolationActionBuilder {
action: ViolationAction,
}

//--------------------------------------------------------------------------------------------------
// Methods
//--------------------------------------------------------------------------------------------------
Expand Down Expand Up @@ -179,14 +186,18 @@ impl NetworkBuilder {
placeholder: placeholder.into(),
allowed_hosts: vec![HostPattern::Exact(allowed_host.into())],
injection: SecretInjection::default(),
on_violation: None,
require_tls_identity: true,
});
self
}

/// Set the violation action for secrets.
pub fn on_secret_violation(mut self, action: ViolationAction) -> Self {
self.config.secrets.on_violation = action;
pub fn on_secret_violation(
mut self,
f: impl FnOnce(ViolationActionBuilder) -> ViolationActionBuilder,
) -> Self {
self.config.secrets.on_violation = f(ViolationActionBuilder::default()).build();
self
}

Expand Down Expand Up @@ -371,6 +382,7 @@ impl SecretBuilder {
placeholder: None,
allowed_hosts: Vec::new(),
injection: SecretInjection::default(),
on_violation: None,
require_tls_identity: true,
}
}
Expand Down Expand Up @@ -416,6 +428,15 @@ impl SecretBuilder {
self
}

/// Set the violation action for this secret.
pub fn on_violation(
mut self,
f: impl FnOnce(ViolationActionBuilder) -> ViolationActionBuilder,
) -> Self {
self.on_violation = Some(f(ViolationActionBuilder::default()).build());
self
}

/// Require verified TLS identity before substituting (default: true).
pub fn require_tls_identity(mut self, enabled: bool) -> Self {
self.require_tls_identity = enabled;
Expand Down Expand Up @@ -463,11 +484,75 @@ impl SecretBuilder {
placeholder,
allowed_hosts: self.allowed_hosts,
injection: self.injection,
on_violation: self.on_violation,
require_tls_identity: self.require_tls_identity,
}
}
}

impl ViolationActionBuilder {
/// Start building a violation action.
pub fn new() -> Self {
Self::default()
}

/// Start building from an existing action.
pub fn from_action(action: ViolationAction) -> Self {
action.into()
}

/// Block the request silently.
pub fn block(mut self) -> Self {
self.action = ViolationAction::Block;
self
}

/// Block the request and emit a warning log.
pub fn block_and_log(mut self) -> Self {
self.action = ViolationAction::BlockAndLog;
self
}

/// Block the request and terminate the sandbox.
pub fn block_and_terminate(mut self) -> Self {
self.action = ViolationAction::BlockAndTerminate;
self
}

/// Allow a host to receive secret placeholders without substitution.
pub fn passthrough_host(mut self, host: impl Into<String>) -> 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<String>) -> Self {
self.push_passthrough_host(HostPattern::Wildcard(pattern.into()));
self
}

/// Allow any host to receive secret placeholders without substitution.
pub fn passthrough_all_hosts(mut self, i_understand_the_risk: bool) -> Self {
if i_understand_the_risk {
self.push_passthrough_host(HostPattern::Any);
}
self
}

/// Helper to accumulate passthrough hosts into the current action.
fn push_passthrough_host(&mut self, host: HostPattern) {
match self.action {
ViolationAction::Passthrough(ref mut hosts) => hosts.push(host),
_ => self.action = ViolationAction::Passthrough(vec![host]),
}
}

/// Consume the builder and return the action.
pub fn build(self) -> ViolationAction {
self.action
}
}

//--------------------------------------------------------------------------------------------------
// Trait Implementations
//--------------------------------------------------------------------------------------------------
Expand All @@ -489,6 +574,11 @@ impl Default for SecretBuilder {
Self::new()
}
}
impl From<ViolationAction> for ViolationActionBuilder {
fn from(action: ViolationAction) -> Self {
Self { action }
}
}

//--------------------------------------------------------------------------------------------------
// Tests
Expand Down Expand Up @@ -524,4 +614,75 @@ mod tests {
assert_eq!(cfg.ports[1].host_bind, bind);
assert_eq!(cfg.ports[1].protocol, PortProtocol::Udp);
}

#[test]
fn network_builder_sets_global_passthrough_action() {
let cfg = NetworkBuilder::new()
.on_secret_violation(|v| {
v.passthrough_host("api.anthropic.com")
.passthrough_host_pattern("*.anthropic.com")
})
.build()
.unwrap();

assert_eq!(
cfg.secrets.on_violation,
ViolationAction::Passthrough(vec![
HostPattern::Exact("api.anthropic.com".into()),
HostPattern::Wildcard("*.anthropic.com".into()),
])
);
}

#[test]
fn secret_builder_sets_violation_action() {
let secret = SecretBuilder::new()
.env("TOKEN")
.value("secret-value")
.allow_host("api.github.com")
.on_violation(|v| {
v.passthrough_host("api.anthropic.com")
.passthrough_host_pattern("*.anthropic.com")
})
.build();

assert_eq!(
secret.on_violation,
Some(ViolationAction::Passthrough(vec![
HostPattern::Exact("api.anthropic.com".into()),
HostPattern::Wildcard("*.anthropic.com".into()),
])),
);
}

#[test]
fn violation_action_builder_blocking_call_replaces_passthrough_policy() {
let action = ViolationActionBuilder::default()
.passthrough_host("google.com")
.block_and_terminate()
.passthrough_host("facebook.com")
.build();

assert_eq!(
action,
ViolationAction::Passthrough(vec![HostPattern::Exact("facebook.com".into())])
);
}

#[test]
fn violation_action_builder_accumulates_passthrough_hosts() {
let action = ViolationActionBuilder::default()
.block()
.passthrough_host("google.com")
.passthrough_host("facebook.com")
.build();

assert_eq!(
action,
ViolationAction::Passthrough(vec![
HostPattern::Exact("google.com".into()),
HostPattern::Exact("facebook.com".into()),
]),
);
}
}
58 changes: 56 additions & 2 deletions crates/network/lib/secrets/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ pub struct SecretEntry {
#[serde(default)]
pub injection: SecretInjection,

/// Action on secret violation for this secret.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_violation: Option<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.
Expand All @@ -46,13 +50,17 @@ pub struct SecretEntry {
}

/// Host pattern for secret allowlist.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HostPattern {
/// Exact hostname match.
#[serde(alias = "Exact")]
Exact(String),
/// Wildcard match (e.g., `*.openai.com`).
#[serde(alias = "Wildcard")]
Wildcard(String),
/// Any host (dangerous — secret can be exfiltrated).
#[serde(alias = "Any")]
Any,
}

Expand All @@ -77,15 +85,22 @@ pub struct SecretInjection {
}

/// Action when a secret placeholder is detected going to a disallowed host.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ViolationAction {
/// Block the request silently.
#[serde(alias = "Block")]
Block,
/// Block and log (default).
#[default]
#[serde(alias = "BlockAndLog", alias = "block_and_log")]
BlockAndLog,
/// Block and terminate the sandbox.
#[serde(alias = "BlockAndTerminate", alias = "block_and_terminate")]
BlockAndTerminate,
/// Forward the request with the placeholder unchanged for matching hosts.
#[serde(alias = "Passthrough")]
Passthrough(Vec<HostPattern>),
}

//--------------------------------------------------------------------------------------------------
Expand All @@ -100,6 +115,7 @@ impl std::fmt::Debug for SecretEntry {
.field("placeholder", &self.placeholder)
.field("allowed_hosts", &self.allowed_hosts)
.field("injection", &self.injection)
.field("on_violation", &self.on_violation)
.field("require_tls_identity", &self.require_tls_identity)
.finish()
}
Expand Down Expand Up @@ -199,8 +215,46 @@ mod tests {
placeholder: "$K".into(),
allowed_hosts: vec![],
injection: SecretInjection::default(),
on_violation: None,
require_tls_identity: true,
};
assert!(entry.require_tls_identity);
}

#[test]
fn violation_action_serializes_with_sdk_casing() {
let action = ViolationAction::Passthrough(vec![
HostPattern::Exact("api.anthropic.com".into()),
HostPattern::Wildcard("*.anthropic.com".into()),
HostPattern::Any,
]);

assert_eq!(
serde_json::to_string(&action).unwrap(),
r#"{"passthrough":[{"exact":"api.anthropic.com"},{"wildcard":"*.anthropic.com"},"any"]}"#
);
assert_eq!(
serde_json::to_string(&ViolationAction::BlockAndLog).unwrap(),
r#""block-and-log""#
);
assert_eq!(
serde_json::to_string(&ViolationAction::BlockAndTerminate).unwrap(),
r#""block-and-terminate""#
);
}

#[test]
fn violation_action_accepts_legacy_pascal_case() {
let action: ViolationAction =
serde_json::from_str(r#"{"Passthrough":[{"Exact":"api.anthropic.com"}]}"#).unwrap();

assert_eq!(
action,
ViolationAction::Passthrough(vec![HostPattern::Exact("api.anthropic.com".into())])
);
assert_eq!(
serde_json::from_str::<ViolationAction>(r#""BlockAndTerminate""#).unwrap(),
ViolationAction::BlockAndTerminate
);
}
}
Loading
Loading