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 smolvm `allow_hosts` list, whose entries
246/// match themselves and any subdomain.
247pub fn covered_by_allow_list(host: &str, allowed: &[String]) -> bool {
248    let host = host.trim_end_matches('.').to_ascii_lowercase();
249    allowed.iter().any(|pattern| {
250        let pattern = pattern.trim_end_matches('.').to_ascii_lowercase();
251        host == pattern || host.ends_with(&format!(".{pattern}"))
252    })
253}
254
255fn valid_binding_name(name: &str) -> bool {
256    !name.is_empty()
257        && name.len() <= 64
258        && name
259            .bytes()
260            .all(|c| c.is_ascii_alphanumeric() || matches!(c, b'_' | b'-' | b'.'))
261}
262
263fn valid_env_name(name: &str) -> bool {
264    let mut bytes = name.bytes();
265    bytes
266        .next()
267        .is_some_and(|c| c.is_ascii_alphabetic() || c == b'_')
268        && bytes.all(|c| c.is_ascii_alphanumeric() || c == b'_')
269}
270
271fn valid_exact_host(host: &str) -> bool {
272    !host.is_empty()
273        && host.len() <= 253
274        && host.parse::<std::net::IpAddr>().is_err()
275        && !host.contains(['/', ':', '*', '@', '?', '#'])
276        && host.split('.').all(|label| {
277            !label.is_empty()
278                && label.len() <= 63
279                && !label.starts_with('-')
280                && !label.ends_with('-')
281                && label
282                    .bytes()
283                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-')
284        })
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    fn notion() -> CredentialBinding {
292        CredentialBinding {
293            name: "notion".into(),
294            environment_variable: "NOTION_API_KEY".into(),
295            allowed_hosts: vec!["api.notion.com".into()],
296            injection_location: InjectionLocation::default(),
297            methods: default_methods(),
298        }
299    }
300
301    #[test]
302    fn parses_the_harness_shape() {
303        let policy: CredentialPolicy = serde_json::from_str(
304            r#"{"credentials":[{"name":"notion","environment_variable":"NOTION_API_KEY",
305               "allowed_hosts":["api.notion.com"],"injection_location":{"header":true}}]}"#,
306        )
307        .unwrap();
308        assert_eq!(policy.credentials, vec![notion()]);
309        policy.validate(None).unwrap();
310    }
311
312    #[test]
313    fn credential_hosts_must_sit_inside_the_network_allow_list() {
314        let policy = CredentialPolicy {
315            credentials: vec![notion()],
316        };
317        policy.validate(Some(&["notion.com".to_string()])).unwrap();
318        policy
319            .validate(Some(&["api.notion.com".to_string()]))
320            .unwrap();
321        let err = policy
322            .validate(Some(&["api.github.com".to_string()]))
323            .unwrap_err();
324        assert!(err.0.contains("network allow_hosts"), "{err}");
325    }
326
327    #[test]
328    fn rejects_empty_wildcard_and_ip_hosts() {
329        for host in [
330            "",
331            "*.notion.com",
332            "10.0.0.1",
333            "API.notion.com",
334            "notion.com:443",
335        ] {
336            let mut b = notion();
337            b.allowed_hosts = vec![host.to_string()];
338            let policy = CredentialPolicy {
339                credentials: vec![b],
340            };
341            assert!(policy.validate(None).is_err(), "{host:?} accepted");
342        }
343        let mut b = notion();
344        b.allowed_hosts.clear();
345        let policy = CredentialPolicy {
346            credentials: vec![b],
347        };
348        assert!(policy
349            .validate(None)
350            .unwrap_err()
351            .0
352            .contains("no allowed_hosts"));
353    }
354
355    #[test]
356    fn rejects_duplicates_and_bad_names() {
357        let policy = CredentialPolicy {
358            credentials: vec![notion(), notion()],
359        };
360        assert!(policy
361            .validate(None)
362            .unwrap_err()
363            .0
364            .contains("declared twice"));
365        let mut other = notion();
366        other.name = "notion-2".into();
367        let policy = CredentialPolicy {
368            credentials: vec![notion(), other],
369        };
370        assert!(policy
371            .validate(None)
372            .unwrap_err()
373            .0
374            .contains("more than one credential"));
375        let mut bad = notion();
376        bad.environment_variable = "1BAD".into();
377        let policy = CredentialPolicy {
378            credentials: vec![bad],
379        };
380        assert!(policy
381            .validate(None)
382            .unwrap_err()
383            .0
384            .contains("not a valid name"));
385    }
386
387    #[test]
388    fn guest_env_maps_placeholders_onto_bound_variables() {
389        let mut github = notion();
390        github.name = "github".into();
391        github.environment_variable = "GITHUB_TOKEN".into();
392        github.allowed_hosts = vec!["api.github.com".into()];
393        let policy = CredentialPolicy {
394            credentials: vec![notion(), github],
395        };
396        let placeholders: BTreeMap<String, String> = [
397            (
398                "notion".to_string(),
399                "SMOL_PLACEHOLDER_NOTION_1".to_string(),
400            ),
401            (
402                "github".to_string(),
403                "SMOL_PLACEHOLDER_GITHUB_2".to_string(),
404            ),
405        ]
406        .into_iter()
407        .collect();
408        let env: Vec<_> = policy.guest_env(&placeholders).collect();
409        assert_eq!(
410            env,
411            vec![
412                (
413                    "NOTION_API_KEY".to_string(),
414                    "SMOL_PLACEHOLDER_NOTION_1".to_string()
415                ),
416                (
417                    "GITHUB_TOKEN".to_string(),
418                    "SMOL_PLACEHOLDER_GITHUB_2".to_string()
419                ),
420            ]
421        );
422        assert!(policy
423            .bindings_for_host("api.github.com")
424            .all(|b| b.name == "github"));
425    }
426}