Skip to main content

microsandbox_network/secrets/
config.rs

1//! Secret injection configuration types.
2
3use serde::{Deserialize, Serialize};
4
5//--------------------------------------------------------------------------------------------------
6// Types
7//--------------------------------------------------------------------------------------------------
8
9/// Configuration for secret injection in a sandbox.
10#[derive(Debug, Clone, Default, Serialize, Deserialize)]
11pub struct SecretsConfig {
12    /// List of secrets to inject.
13    #[serde(default)]
14    pub secrets: Vec<SecretEntry>,
15
16    /// Action on secret violation (placeholder leaked to disallowed host).
17    #[serde(default)]
18    pub on_violation: ViolationAction,
19}
20
21/// A single secret entry (serializable form passed to the network engine).
22#[derive(Clone, Serialize, Deserialize)]
23pub struct SecretEntry {
24    /// Environment variable name exposed to the sandbox (holds the placeholder).
25    pub env_var: String,
26
27    /// The actual secret value (never enters the sandbox).
28    pub value: String,
29
30    /// Placeholder string the sandbox sees instead of the real value.
31    pub placeholder: String,
32
33    /// Hosts allowed to receive this secret.
34    #[serde(default)]
35    pub allowed_hosts: Vec<HostPattern>,
36
37    /// Where the secret can be injected.
38    #[serde(default)]
39    pub injection: SecretInjection,
40
41    /// Action on secret violation for this secret.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub on_violation: Option<ViolationAction>,
44
45    /// Require verified TLS identity before substituting (default: true).
46    /// When true, secret is only substituted if the connection uses TLS
47    /// interception (not bypass) and the SNI matches an allowed host.
48    #[serde(default = "default_true")]
49    pub require_tls_identity: bool,
50}
51
52/// Host pattern for secret allowlist.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "kebab-case")]
55pub enum HostPattern {
56    /// Exact hostname match.
57    #[serde(alias = "Exact")]
58    Exact(String),
59    /// Wildcard match (e.g., `*.openai.com`).
60    #[serde(alias = "Wildcard")]
61    Wildcard(String),
62    /// Any host (dangerous — secret can be exfiltrated).
63    #[serde(alias = "Any")]
64    Any,
65}
66
67/// Where in the HTTP request the secret can be injected.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct SecretInjection {
70    /// Substitute in HTTP headers (default: true).
71    #[serde(default = "default_true")]
72    pub headers: bool,
73
74    /// Substitute in HTTP Basic Auth (default: true).
75    #[serde(default = "default_true")]
76    pub basic_auth: bool,
77
78    /// Substitute in URL query parameters (default: false).
79    #[serde(default)]
80    pub query_params: bool,
81
82    /// Substitute in request body (default: false).
83    #[serde(default)]
84    pub body: bool,
85}
86
87/// Action when a secret placeholder is detected going to a disallowed host.
88#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "kebab-case")]
90pub enum ViolationAction {
91    /// Block the request silently.
92    #[serde(alias = "Block")]
93    Block,
94    /// Block and log (default).
95    #[default]
96    #[serde(alias = "BlockAndLog", alias = "block_and_log")]
97    BlockAndLog,
98    /// Block and terminate the sandbox.
99    #[serde(alias = "BlockAndTerminate", alias = "block_and_terminate")]
100    BlockAndTerminate,
101    /// Forward the request with the placeholder unchanged for matching hosts.
102    #[serde(alias = "Passthrough")]
103    Passthrough(Vec<HostPattern>),
104}
105
106//--------------------------------------------------------------------------------------------------
107// Methods
108//--------------------------------------------------------------------------------------------------
109
110impl std::fmt::Debug for SecretEntry {
111    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112        f.debug_struct("SecretEntry")
113            .field("env_var", &self.env_var)
114            .field("value", &"[REDACTED]")
115            .field("placeholder", &self.placeholder)
116            .field("allowed_hosts", &self.allowed_hosts)
117            .field("injection", &self.injection)
118            .field("on_violation", &self.on_violation)
119            .field("require_tls_identity", &self.require_tls_identity)
120            .finish()
121    }
122}
123
124impl HostPattern {
125    /// Check if a hostname matches this pattern.
126    ///
127    /// Uses ASCII case-insensitive comparison to avoid `to_lowercase()`
128    /// allocations (DNS hostnames are ASCII per RFC 4343).
129    pub fn matches(&self, hostname: &str) -> bool {
130        match self {
131            HostPattern::Exact(h) => hostname.eq_ignore_ascii_case(h),
132            HostPattern::Wildcard(pattern) => {
133                if let Some(suffix) = pattern.strip_prefix("*.") {
134                    hostname.eq_ignore_ascii_case(suffix)
135                        || (hostname.len() > suffix.len() + 1
136                            && hostname.as_bytes()[hostname.len() - suffix.len() - 1] == b'.'
137                            && hostname[hostname.len() - suffix.len()..]
138                                .eq_ignore_ascii_case(suffix))
139                } else {
140                    hostname.eq_ignore_ascii_case(pattern)
141                }
142            }
143            HostPattern::Any => true,
144        }
145    }
146}
147
148//--------------------------------------------------------------------------------------------------
149// Trait Implementations
150//--------------------------------------------------------------------------------------------------
151
152impl Default for SecretInjection {
153    fn default() -> Self {
154        Self {
155            headers: true,
156            basic_auth: true,
157            query_params: false,
158            body: false,
159        }
160    }
161}
162
163//--------------------------------------------------------------------------------------------------
164// Functions
165//--------------------------------------------------------------------------------------------------
166
167fn default_true() -> bool {
168    true
169}
170
171//--------------------------------------------------------------------------------------------------
172// Tests
173//--------------------------------------------------------------------------------------------------
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn exact_host_match() {
181        let p = HostPattern::Exact("api.openai.com".into());
182        assert!(p.matches("api.openai.com"));
183        assert!(p.matches("API.OpenAI.com"));
184        assert!(!p.matches("evil.com"));
185    }
186
187    #[test]
188    fn wildcard_host_match() {
189        let p = HostPattern::Wildcard("*.openai.com".into());
190        assert!(p.matches("api.openai.com"));
191        assert!(p.matches("openai.com"));
192        assert!(!p.matches("evil.com"));
193    }
194
195    #[test]
196    fn any_host_match() {
197        let p = HostPattern::Any;
198        assert!(p.matches("anything.com"));
199    }
200
201    #[test]
202    fn default_injection_scopes() {
203        let inj = SecretInjection::default();
204        assert!(inj.headers);
205        assert!(inj.basic_auth);
206        assert!(!inj.query_params);
207        assert!(!inj.body);
208    }
209
210    #[test]
211    fn default_require_tls_identity() {
212        let entry = SecretEntry {
213            env_var: "K".into(),
214            value: "v".into(),
215            placeholder: "$K".into(),
216            allowed_hosts: vec![],
217            injection: SecretInjection::default(),
218            on_violation: None,
219            require_tls_identity: true,
220        };
221        assert!(entry.require_tls_identity);
222    }
223
224    #[test]
225    fn violation_action_serializes_with_sdk_casing() {
226        let action = ViolationAction::Passthrough(vec![
227            HostPattern::Exact("api.anthropic.com".into()),
228            HostPattern::Wildcard("*.anthropic.com".into()),
229            HostPattern::Any,
230        ]);
231
232        assert_eq!(
233            serde_json::to_string(&action).unwrap(),
234            r#"{"passthrough":[{"exact":"api.anthropic.com"},{"wildcard":"*.anthropic.com"},"any"]}"#
235        );
236        assert_eq!(
237            serde_json::to_string(&ViolationAction::BlockAndLog).unwrap(),
238            r#""block-and-log""#
239        );
240        assert_eq!(
241            serde_json::to_string(&ViolationAction::BlockAndTerminate).unwrap(),
242            r#""block-and-terminate""#
243        );
244    }
245
246    #[test]
247    fn violation_action_accepts_legacy_pascal_case() {
248        let action: ViolationAction =
249            serde_json::from_str(r#"{"Passthrough":[{"Exact":"api.anthropic.com"}]}"#).unwrap();
250
251        assert_eq!(
252            action,
253            ViolationAction::Passthrough(vec![HostPattern::Exact("api.anthropic.com".into())])
254        );
255        assert_eq!(
256            serde_json::from_str::<ViolationAction>(r#""BlockAndTerminate""#).unwrap(),
257            ViolationAction::BlockAndTerminate
258        );
259    }
260}