Skip to main content

assay_core/config/
otel.rs

1use serde::{Deserialize, Serialize};
2
3#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
4#[serde(default)]
5pub struct OtelConfig {
6    /// GenAI Semantic Conventions version to anchor span attributes.
7    /// Default: "1.28.0" (Bleeding Edge 2026)
8    pub genai_semconv_version: String,
9
10    /// Stability control for attributes.
11    /// "stable_only" (default) or "experimental_opt_in".
12    pub semconv_stability: SemConvStability,
13
14    /// Privacy control for prompt/response payloads.
15    /// "off" (default and invariant) MUST NOT emit payloads inline.
16    #[serde(rename = "capture_mode")]
17    pub capture_mode: PromptCaptureMode,
18
19    /// Redaction Settings (if capture is enabled).
20    pub redaction: RedactionConfig,
21
22    /// Telemetry Surface Guardrails (Anti-OpenClaw).
23    pub exporter: ExporterConfig,
24
25    /// explicit acknowledgement required to enable capture (Two-person rule/Anti-misconfig).
26    #[serde(default)]
27    pub capture_acknowledged: bool,
28
29    /// Whether to require a sampled span before capturing payloads (prevents ghost costs).
30    #[serde(default = "default_true")]
31    pub capture_requires_sampled_span: bool,
32}
33
34fn default_true() -> bool {
35    true
36}
37
38impl Default for OtelConfig {
39    fn default() -> Self {
40        Self {
41            genai_semconv_version: "1.28.0".to_string(),
42            semconv_stability: SemConvStability::default(),
43            capture_mode: PromptCaptureMode::default(),
44            redaction: RedactionConfig::default(),
45            exporter: ExporterConfig::default(),
46            capture_acknowledged: false,
47            capture_requires_sampled_span: true,
48        }
49    }
50}
51
52#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Default)]
53#[serde(rename_all = "snake_case")]
54pub enum SemConvStability {
55    #[default]
56    StableOnly,
57    ExperimentalOptIn,
58}
59
60#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Default)]
61#[serde(rename_all = "snake_case")]
62pub enum PromptCaptureMode {
63    #[default]
64    Off,
65    RedactedInline,
66    BlobRef,
67}
68
69#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Default)]
70pub struct RedactionConfig {
71    // Basic regex-based redactions
72    #[serde(default)]
73    pub policies: Vec<String>,
74}
75
76#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Default)]
77pub struct ExporterConfig {
78    // Explicit allowlist for OTLP destinations if payload capture is on
79    #[serde(default)]
80    pub allowlist: Option<Vec<String>>,
81
82    /// Allow binding/exporting to localhost loopback (Anti-OpenClaw debug surface protection).
83    /// Default: false (Deny)
84    #[serde(default)]
85    pub allow_localhost: bool,
86}
87
88impl OtelConfig {
89    pub fn validate(&self) -> Result<(), String> {
90        if matches!(self.capture_mode, PromptCaptureMode::Off) {
91            return Ok(());
92        }
93
94        // 0. Anti-Misconfiguration Guard (Two-person rule)
95        if !self.capture_acknowledged {
96            return Err(
97                "OpenClaw: 'otel.capture_acknowledged' must be true when capture_mode is enabled."
98                    .to_string(),
99            );
100        }
101
102        // OpenClaw Guardrails: If capture is enabled, strict security is required.
103
104        // 1. TLS Enforcement
105        let endpoint = std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").unwrap_or_default();
106        if !endpoint.is_empty()
107            && !endpoint.starts_with("https://")
108            && !endpoint.starts_with("http://localhost")
109        {
110            // Allow localhost for dev, but require HTTPS for remote
111            return Err(
112                "OpenClaw: OTLP endpoint must use TLS (https://) when payload capture is enabled."
113                    .to_string(),
114            );
115        }
116
117        // 2. Explicit Allowlist
118        if let Some(list) = &self.exporter.allowlist {
119            if !endpoint.is_empty() {
120                // Check if endpoint domain/prefix is in allowlist (Wildcard Support)
121                let allowed = list
122                    .iter()
123                    .any(|rule| Self::matches_allowlist(&endpoint, rule));
124                if !allowed {
125                    return Err(format!(
126                        "OpenClaw: OTLP endpoint '{}' is not in the explicit allowlist.",
127                        endpoint
128                    ));
129                }
130            }
131        } else {
132            // If capture is ON, allowlist is MANDATORY
133            return Err("OpenClaw: An explicit 'exporter.allowlist' is required when payload capture is enabled.".to_string());
134        }
135
136        // 3. Localhost Binding Guard
137        if !self.exporter.allow_localhost
138            && (endpoint.contains("localhost")
139                || endpoint.contains("127.0.0.1")
140                || endpoint.contains("::1"))
141        {
142            return Err("OpenClaw: Export to localhost is blocked by default. Set 'exporter.allow_localhost = true' to enable.".to_string());
143        }
144
145        // 4. BlobRef: ASSAY_ORG_SECRET required (no ephemeral key in prod; hashes would be guessable across installs).
146        if matches!(self.capture_mode, PromptCaptureMode::BlobRef) {
147            let secret = std::env::var("ASSAY_ORG_SECRET").unwrap_or_default();
148            if secret.is_empty() || secret == "ephemeral-key" {
149                return Err("OpenClaw: BlobRef mode requires ASSAY_ORG_SECRET to be set (no ephemeral key).".to_string());
150            }
151        }
152
153        Ok(())
154    }
155
156    /// Check if host matches allowlist rule (Exact or *.wildcard).
157    /// Uses strict URL parsing to avoid substring/ipv6 bypasses.
158    fn matches_allowlist(endpoint: &str, rule: &str) -> bool {
159        // Use parsing to extract host reliably
160        let host_str = if endpoint.contains("://") {
161            if let Ok(url) = url::Url::parse(endpoint) {
162                url.host_str().map(|h| h.to_string())
163            } else {
164                None // Invalid URL, block it safely
165            }
166        } else {
167            // Fallback: split by colon if no scheme.
168            endpoint.split(':').next().map(|s| s.to_string())
169        };
170
171        let Some(host) = host_str else {
172            // Fail closed if we can't parse host
173            return false;
174        };
175
176        // Host normalization (lowercase)
177        let host = host.to_lowercase();
178        let rule = rule.to_lowercase();
179
180        if rule.starts_with("*.") {
181            let suffix = &rule[1..]; // keep dot: ".trusted.org"
182            host.ends_with(suffix) && !host.strip_suffix(suffix).unwrap_or("").contains('.')
183        } else {
184            host == rule
185        }
186    }
187}
188
189#[cfg(test)]
190#[allow(unsafe_code)]
191mod tests {
192    use super::*;
193    use serial_test::serial;
194
195    #[test]
196    #[serial]
197    fn test_guardrails_validation() {
198        let mut cfg = OtelConfig {
199            capture_mode: PromptCaptureMode::RedactedInline,
200            capture_acknowledged: true,
201            exporter: ExporterConfig {
202                allowlist: None,
203                ..Default::default()
204            },
205            ..Default::default()
206        };
207
208        // 1. Unset env var -> Error (No allowlist provided)
209        unsafe {
210            std::env::remove_var("OTEL_EXPORTER_OTLP_ENDPOINT");
211        }
212        let res = cfg.validate();
213        assert!(
214            res.is_err(),
215            "Should fail without allowlist when capture is on"
216        );
217
218        // 2. Set Allowlist, but bad Endpoint (HTTP)
219        cfg.exporter.allowlist = Some(vec!["example.com".to_string()]);
220
221        unsafe {
222            std::env::set_var("OTEL_EXPORTER_OTLP_ENDPOINT", "http://example.com");
223        }
224        let res = cfg.validate();
225        assert!(res.is_err(), "Should fail HTTP endpoint");
226
227        // 3. Good Endpoint (HTTPS + Allowlist match)
228        unsafe {
229            std::env::set_var("OTEL_EXPORTER_OTLP_ENDPOINT", "https://example.com");
230        }
231        let res = cfg.validate();
232        assert!(res.is_ok(), "Should pass HTTPS + Allowlist");
233
234        // 4. Boundary/Attack Tests (Audit Requirement)
235        cfg.exporter.allowlist = Some(vec!["example.com".to_string(), "*.trusted.org".to_string()]);
236
237        // Case A: Suffix Attack (example.com.attacker.tld)
238        unsafe {
239            std::env::set_var(
240                "OTEL_EXPORTER_OTLP_ENDPOINT",
241                "https://example.com.attacker.tld",
242            );
243        }
244        assert!(cfg.validate().is_err(), "Must block suffix spoofing");
245
246        // Case B: Prefix Attack (evilexample.com)
247        unsafe {
248            std::env::set_var("OTEL_EXPORTER_OTLP_ENDPOINT", "https://evilexample.com");
249        }
250        assert!(cfg.validate().is_err(), "Must block prefix spoofing");
251
252        // Case C: Trusted Wildcard
253        unsafe {
254            std::env::set_var("OTEL_EXPORTER_OTLP_ENDPOINT", "https://api.trusted.org");
255        }
256        assert!(cfg.validate().is_ok(), "Must allow valid wildcard child");
257
258        // 5. Clean up
259        unsafe {
260            std::env::remove_var("OTEL_EXPORTER_OTLP_ENDPOINT");
261        }
262    }
263
264    /// Sign-off: allowlist wildcard *.mycorp.com allows otel.mycorp.com; denies evilmycorp.com (no substring).
265    #[test]
266    #[serial]
267    fn test_allowlist_wildcard_mycorp_allowed_evil_denied() {
268        let cfg = OtelConfig {
269            capture_mode: PromptCaptureMode::BlobRef,
270            capture_acknowledged: true,
271            exporter: ExporterConfig {
272                allowlist: Some(vec!["*.mycorp.com".to_string()]),
273                ..Default::default()
274            },
275            ..Default::default()
276        };
277
278        unsafe {
279            std::env::set_var("ASSAY_ORG_SECRET", "test-secret");
280        }
281        unsafe {
282            std::env::set_var("OTEL_EXPORTER_OTLP_ENDPOINT", "https://otel.mycorp.com");
283        }
284        assert!(
285            cfg.validate().is_ok(),
286            "*.mycorp.com must allow https://otel.mycorp.com"
287        );
288
289        unsafe {
290            std::env::set_var("OTEL_EXPORTER_OTLP_ENDPOINT", "https://evilmycorp.com");
291        }
292        assert!(
293            cfg.validate().is_err(),
294            "*.mycorp.com must NOT allow https://evilmycorp.com (substring bypass)"
295        );
296
297        unsafe {
298            std::env::remove_var("OTEL_EXPORTER_OTLP_ENDPOINT");
299        }
300        unsafe {
301            std::env::remove_var("ASSAY_ORG_SECRET");
302        }
303    }
304
305    /// Sign-off: port and trailing-dot edge cases (host extraction via url crate).
306    #[test]
307    #[serial]
308    fn test_allowlist_port_and_trailing_dot() {
309        let cfg = OtelConfig {
310            capture_mode: PromptCaptureMode::RedactedInline,
311            capture_acknowledged: true,
312            exporter: ExporterConfig {
313                allowlist: Some(vec!["otel.mycorp.com".to_string()]),
314                ..Default::default()
315            },
316            ..Default::default()
317        };
318
319        unsafe {
320            std::env::set_var("OTEL_EXPORTER_OTLP_ENDPOINT", "https://otel.mycorp.com:443");
321        }
322        assert!(
323            cfg.validate().is_ok(),
324            "Host with port must match by host only"
325        );
326
327        unsafe {
328            std::env::remove_var("OTEL_EXPORTER_OTLP_ENDPOINT");
329        }
330    }
331
332    /// Sign-off: allow_localhost default deny; explicit true allows localhost.
333    #[test]
334    #[serial]
335    fn test_allow_localhost_default_deny_explicit_true_allowed() {
336        let mut cfg = OtelConfig {
337            capture_mode: PromptCaptureMode::BlobRef,
338            capture_acknowledged: true,
339            exporter: ExporterConfig {
340                allowlist: Some(vec!["127.0.0.1".to_string()]),
341                allow_localhost: false,
342            },
343            ..Default::default()
344        };
345
346        unsafe {
347            std::env::set_var("ASSAY_ORG_SECRET", "test-secret");
348        }
349        unsafe {
350            std::env::set_var("OTEL_EXPORTER_OTLP_ENDPOINT", "https://127.0.0.1");
351        }
352        assert!(
353            cfg.validate().is_err(),
354            "allow_localhost=false must block localhost"
355        );
356
357        cfg.exporter.allow_localhost = true;
358        assert!(
359            cfg.validate().is_ok(),
360            "allow_localhost=true must allow when in allowlist"
361        );
362
363        unsafe {
364            std::env::remove_var("OTEL_EXPORTER_OTLP_ENDPOINT");
365        }
366        unsafe {
367            std::env::remove_var("ASSAY_ORG_SECRET");
368        }
369    }
370
371    /// Sign-off: BlobRef requires ASSAY_ORG_SECRET (fail when unset or ephemeral-key).
372    #[test]
373    #[serial]
374    fn test_blob_ref_requires_assay_org_secret() {
375        let cfg = OtelConfig {
376            capture_mode: PromptCaptureMode::BlobRef,
377            capture_acknowledged: true,
378            exporter: ExporterConfig {
379                allowlist: Some(vec!["example.com".to_string()]),
380                ..Default::default()
381            },
382            ..Default::default()
383        };
384
385        unsafe {
386            std::env::remove_var("ASSAY_ORG_SECRET");
387        }
388        unsafe {
389            std::env::set_var("OTEL_EXPORTER_OTLP_ENDPOINT", "https://example.com");
390        }
391        assert!(
392            cfg.validate().is_err(),
393            "BlobRef must fail when ASSAY_ORG_SECRET unset"
394        );
395
396        unsafe {
397            std::env::set_var("ASSAY_ORG_SECRET", "ephemeral-key");
398        }
399        assert!(
400            cfg.validate().is_err(),
401            "BlobRef must fail when ASSAY_ORG_SECRET is ephemeral-key"
402        );
403
404        unsafe {
405            std::env::set_var("ASSAY_ORG_SECRET", "prod-secret-xyz");
406        }
407        assert!(
408            cfg.validate().is_ok(),
409            "BlobRef must pass when ASSAY_ORG_SECRET set"
410        );
411
412        unsafe {
413            std::env::remove_var("ASSAY_ORG_SECRET");
414        }
415        unsafe {
416            std::env::remove_var("OTEL_EXPORTER_OTLP_ENDPOINT");
417        }
418    }
419}