Skip to main content

smolvm_protocol/
credentials.rs

1//! Credential policy: which credential bindings a machine may use, and where.
2//!
3//! Shared between the Smolfile parser, the machine record, the API and the
4//! interceptor in `smolvm-credentials`, so every surface speaks one shape.
5//!
6//! The shape mirrors the egress policy used by agent harnesses that target
7//! several sandbox backends, so a caller can hand the same document to every
8//! backend it supports:
9//!
10//! ```json
11//! {
12//!   "credentials": [{
13//!     "name": "notion",
14//!     "environment_variable": "NOTION_API_KEY",
15//!     "allowed_hosts": ["api.notion.com"],
16//!     "injection_location": { "header": true }
17//!   }]
18//! }
19//! ```
20//!
21//! A binding names a credential; it never carries the value. Every binding
22//! lists the exact hosts substitution is permitted for, independently of the
23//! machine's network allow-list — widening the network never widens a
24//! credential, and a credential with no hosts is a configuration error rather
25//! than an implicit "anywhere".
26
27use serde::{Deserialize, Serialize};
28use std::collections::{BTreeMap, BTreeSet};
29use std::fmt;
30
31/// Prefix every generated placeholder starts with. The interceptor refuses
32/// requests that carry this prefix anywhere it does not substitute (path,
33/// query, body, unsupported headers) so a placeholder can never leak upstream
34/// unreplaced.
35pub const PLACEHOLDER_PREFIX: &str = "SMOL_PLACEHOLDER_";
36
37/// Guest directory the machine's public credential CA is mounted at.
38pub const GUEST_CA_DIR: &str = "/run/smol/credentials";
39/// File name of the public CA certificate inside [`GUEST_CA_DIR`].
40pub const GUEST_CA_FILE: &str = "ca.pem";
41/// Bundle the guest agent assembles from the image's own trust roots plus the
42/// machine CA, so clients whose CA variable *replaces* the system store
43/// (OpenSSL, curl, Python `requests`, Git) keep trusting public hosts.
44pub const GUEST_CA_BUNDLE: &str = "/run/smolvm/ca-bundle.pem";
45
46/// Environment the guest receives so common HTTP clients trust the machine CA.
47pub const GUEST_TRUST_ENV: &[(&str, &str)] = &[
48    ("SSL_CERT_FILE", GUEST_CA_BUNDLE),
49    ("CURL_CA_BUNDLE", GUEST_CA_BUNDLE),
50    ("REQUESTS_CA_BUNDLE", GUEST_CA_BUNDLE),
51    ("GIT_SSL_CAINFO", GUEST_CA_BUNDLE),
52    ("NODE_EXTRA_CA_CERTS", "/run/smol/credentials/ca.pem"),
53    ("DENO_CERT", "/run/smol/credentials/ca.pem"),
54];
55
56/// HTTP methods a binding may be used with unless it says otherwise.
57pub const DEFAULT_METHODS: &[&str] = &["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
58
59const SUPPORTED_METHODS: &[&str] = DEFAULT_METHODS;
60
61/// Where the interceptor substitutes a placeholder.
62#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(deny_unknown_fields)]
64pub struct InjectionLocation {
65    /// Substitute inside request header values (`Authorization`, `x-api-key`,
66    /// or any other ordinary header). Routing and framing headers are excluded.
67    #[serde(default = "yes")]
68    pub header: bool,
69}
70
71fn yes() -> bool {
72    true
73}
74
75impl Default for InjectionLocation {
76    fn default() -> Self {
77        Self { header: true }
78    }
79}
80
81/// One named credential a workload may use against explicit hosts.
82#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(deny_unknown_fields)]
84pub struct CredentialBinding {
85    /// Binding name handed to the resolver. Scoped by the caller, never a
86    /// storage identifier.
87    pub name: String,
88    /// Guest environment variable that receives the placeholder.
89    pub environment_variable: String,
90    /// Exact DNS hosts (lowercase, no wildcard, no scheme or port) the
91    /// credential may be sent to.
92    pub allowed_hosts: Vec<String>,
93    /// Where substitution happens.
94    #[serde(default)]
95    pub injection_location: InjectionLocation,
96    /// HTTP methods the binding may be used with. Defaults to every supported
97    /// method; narrow it for read-only tokens.
98    #[serde(default = "default_methods")]
99    pub methods: Vec<String>,
100}
101
102fn default_methods() -> Vec<String> {
103    DEFAULT_METHODS.iter().map(|m| m.to_string()).collect()
104}
105
106impl CredentialBinding {
107    /// Whether this binding permits substitution toward `host`.
108    pub fn allows_host(&self, host: &str) -> bool {
109        let host = host.trim_end_matches('.');
110        self.allowed_hosts
111            .iter()
112            .any(|allowed| allowed.eq_ignore_ascii_case(host))
113    }
114
115    /// Whether this binding permits `method`.
116    pub fn allows_method(&self, method: &str) -> bool {
117        self.methods.iter().any(|m| m.eq_ignore_ascii_case(method))
118    }
119}
120
121/// The complete credential section of a machine's network policy.
122#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(deny_unknown_fields, default)]
124pub struct CredentialPolicy {
125    /// Bindings the machine may use, in declaration order.
126    pub credentials: Vec<CredentialBinding>,
127}
128
129/// Why a policy was refused.
130#[derive(Clone, Debug, PartialEq, Eq)]
131pub struct PolicyError(pub String);
132
133impl fmt::Display for PolicyError {
134    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135        f.write_str(&self.0)
136    }
137}
138
139impl std::error::Error for PolicyError {}
140
141fn refuse(message: String) -> Result<(), PolicyError> {
142    Err(PolicyError(message))
143}
144
145impl CredentialPolicy {
146    /// No bindings.
147    pub fn is_empty(&self) -> bool {
148        self.credentials.is_empty()
149    }
150
151    /// Validate structure and, when the machine also restricts egress by
152    /// hostname, require every credential host to be reachable under that
153    /// allow-list. `network_allowed_hosts = None` means the machine's network is
154    /// not hostname-restricted; the credential's own list still applies.
155    pub fn validate(&self, network_allowed_hosts: Option<&[String]>) -> Result<(), PolicyError> {
156        let mut names = BTreeSet::new();
157        let mut env_vars = BTreeSet::new();
158        for binding in &self.credentials {
159            let name = &binding.name;
160            let env_var = &binding.environment_variable;
161            if !valid_binding_name(name) {
162                return refuse(format!(
163                    "credential name {name:?} is not a valid binding name"
164                ));
165            }
166            if !valid_env_name(env_var) {
167                return refuse(format!(
168                    "credential environment variable {env_var:?} is not a valid name"
169                ));
170            }
171            if !names.insert(name.as_str()) {
172                return refuse(format!("credential {name:?} is declared twice"));
173            }
174            if !env_vars.insert(env_var.as_str()) {
175                return refuse(format!(
176                    "environment variable {env_var:?} is bound to more than one credential"
177                ));
178            }
179            if binding.allowed_hosts.is_empty() {
180                return refuse(format!(
181                    "credential {name:?} lists no allowed_hosts; a credential is never sent anywhere by default"
182                ));
183            }
184            for host in &binding.allowed_hosts {
185                if !valid_exact_host(host) {
186                    return refuse(format!(
187                        "credential {name:?} host {host:?} must be an exact lowercase DNS name (no wildcard, scheme, port or IP)"
188                    ));
189                }
190                if network_allowed_hosts
191                    .is_some_and(|allowed| !covered_by_allow_list(host, allowed))
192                {
193                    return refuse(format!(
194                        "credential {name:?} host {host:?} is not reachable under the machine's network allow_hosts"
195                    ));
196                }
197            }
198            if binding.methods.is_empty() {
199                return refuse(format!("credential {name:?} allows no HTTP methods"));
200            }
201            if let Some(method) = binding.methods.iter().find(|method| {
202                !SUPPORTED_METHODS
203                    .iter()
204                    .any(|m| m.eq_ignore_ascii_case(method))
205            }) {
206                return refuse(format!(
207                    "credential {name:?} method {method:?} is not supported"
208                ));
209            }
210            if !binding.injection_location.header {
211                return refuse(format!("credential {name:?} enables no injection location"));
212            }
213        }
214        Ok(())
215    }
216
217    /// Bindings permitted to substitute toward `host`, in declaration order.
218    pub fn bindings_for_host<'a>(
219        &'a self,
220        host: &'a str,
221    ) -> impl Iterator<Item = &'a CredentialBinding> + 'a {
222        self.credentials.iter().filter(move |b| b.allows_host(host))
223    }
224
225    /// Look up a binding by name.
226    pub fn binding(&self, name: &str) -> Option<&CredentialBinding> {
227        self.credentials.iter().find(|b| b.name == name)
228    }
229
230    /// Guest environment assignments (`environment_variable`, placeholder).
231    /// Bindings without a placeholder are skipped; callers persist placeholders
232    /// alongside the policy so this can never silently regenerate them.
233    pub fn guest_env<'a>(
234        &'a self,
235        placeholders: &'a BTreeMap<String, String>,
236    ) -> impl Iterator<Item = (String, String)> + 'a {
237        self.credentials.iter().filter_map(move |b| {
238            placeholders
239                .get(&b.name)
240                .map(|p| (b.environment_variable.clone(), p.clone()))
241        })
242    }
243}
244
245/// Whether `host` is admitted by a legacy or opt-in network allow-list entry.
246pub fn covered_by_allow_list(host: &str, allowed: &[String]) -> bool {
247    allowed
248        .iter()
249        .any(|pattern| crate::host_pattern::matches(host, pattern))
250}
251
252fn valid_binding_name(name: &str) -> bool {
253    !name.is_empty()
254        && name.len() <= 64
255        && name
256            .bytes()
257            .all(|c| c.is_ascii_alphanumeric() || matches!(c, b'_' | b'-' | b'.'))
258}
259
260fn valid_env_name(name: &str) -> bool {
261    let mut bytes = name.bytes();
262    bytes
263        .next()
264        .is_some_and(|c| c.is_ascii_alphabetic() || c == b'_')
265        && bytes.all(|c| c.is_ascii_alphanumeric() || c == b'_')
266}
267
268fn valid_exact_host(host: &str) -> bool {
269    !host.is_empty()
270        && host.len() <= 253
271        && host.parse::<std::net::IpAddr>().is_err()
272        && !host.contains(['/', ':', '*', '@', '?', '#'])
273        && host.split('.').all(|label| {
274            !label.is_empty()
275                && label.len() <= 63
276                && !label.starts_with('-')
277                && !label.ends_with('-')
278                && label
279                    .bytes()
280                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-')
281        })
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    fn notion() -> CredentialBinding {
289        CredentialBinding {
290            name: "notion".into(),
291            environment_variable: "NOTION_API_KEY".into(),
292            allowed_hosts: vec!["api.notion.com".into()],
293            injection_location: InjectionLocation::default(),
294            methods: default_methods(),
295        }
296    }
297
298    #[test]
299    fn parses_the_harness_shape() {
300        let policy: CredentialPolicy = serde_json::from_str(
301            r#"{"credentials":[{"name":"notion","environment_variable":"NOTION_API_KEY",
302               "allowed_hosts":["api.notion.com"],"injection_location":{"header":true}}]}"#,
303        )
304        .unwrap();
305        assert_eq!(policy.credentials, vec![notion()]);
306        policy.validate(None).unwrap();
307    }
308
309    #[test]
310    fn credential_hosts_must_sit_inside_the_network_allow_list() {
311        let policy = CredentialPolicy {
312            credentials: vec![notion()],
313        };
314        policy.validate(Some(&["notion.com".to_string()])).unwrap();
315        policy
316            .validate(Some(&["*.notion.com".to_string()]))
317            .unwrap();
318        policy
319            .validate(Some(&["api.notion.com".to_string()]))
320            .unwrap();
321        assert!(policy.validate(Some(&["=notion.com".to_string()])).is_err());
322        let err = policy
323            .validate(Some(&["api.github.com".to_string()]))
324            .unwrap_err();
325        assert!(err.0.contains("network allow_hosts"), "{err}");
326    }
327
328    #[test]
329    fn rejects_empty_wildcard_and_ip_hosts() {
330        for host in [
331            "",
332            "*.notion.com",
333            "10.0.0.1",
334            "API.notion.com",
335            "notion.com:443",
336        ] {
337            let mut b = notion();
338            b.allowed_hosts = vec![host.to_string()];
339            let policy = CredentialPolicy {
340                credentials: vec![b],
341            };
342            assert!(policy.validate(None).is_err(), "{host:?} accepted");
343        }
344        let mut b = notion();
345        b.allowed_hosts.clear();
346        let policy = CredentialPolicy {
347            credentials: vec![b],
348        };
349        assert!(policy
350            .validate(None)
351            .unwrap_err()
352            .0
353            .contains("no allowed_hosts"));
354    }
355
356    #[test]
357    fn rejects_duplicates_and_bad_names() {
358        let policy = CredentialPolicy {
359            credentials: vec![notion(), notion()],
360        };
361        assert!(policy
362            .validate(None)
363            .unwrap_err()
364            .0
365            .contains("declared twice"));
366        let mut other = notion();
367        other.name = "notion-2".into();
368        let policy = CredentialPolicy {
369            credentials: vec![notion(), other],
370        };
371        assert!(policy
372            .validate(None)
373            .unwrap_err()
374            .0
375            .contains("more than one credential"));
376        let mut bad = notion();
377        bad.environment_variable = "1BAD".into();
378        let policy = CredentialPolicy {
379            credentials: vec![bad],
380        };
381        assert!(policy
382            .validate(None)
383            .unwrap_err()
384            .0
385            .contains("not a valid name"));
386    }
387
388    #[test]
389    fn guest_env_maps_placeholders_onto_bound_variables() {
390        let mut github = notion();
391        github.name = "github".into();
392        github.environment_variable = "GITHUB_TOKEN".into();
393        github.allowed_hosts = vec!["api.github.com".into()];
394        let policy = CredentialPolicy {
395            credentials: vec![notion(), github],
396        };
397        let placeholders: BTreeMap<String, String> = [
398            (
399                "notion".to_string(),
400                "SMOL_PLACEHOLDER_NOTION_1".to_string(),
401            ),
402            (
403                "github".to_string(),
404                "SMOL_PLACEHOLDER_GITHUB_2".to_string(),
405            ),
406        ]
407        .into_iter()
408        .collect();
409        let env: Vec<_> = policy.guest_env(&placeholders).collect();
410        assert_eq!(
411            env,
412            vec![
413                (
414                    "NOTION_API_KEY".to_string(),
415                    "SMOL_PLACEHOLDER_NOTION_1".to_string()
416                ),
417                (
418                    "GITHUB_TOKEN".to_string(),
419                    "SMOL_PLACEHOLDER_GITHUB_2".to_string()
420                ),
421            ]
422        );
423        assert!(policy
424            .bindings_for_host("api.github.com")
425            .all(|b| b.name == "github"));
426    }
427}