1use serde::{Deserialize, Serialize};
2
3#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
4#[serde(default)]
5pub struct OtelConfig {
6 pub genai_semconv_version: String,
9
10 pub semconv_stability: SemConvStability,
13
14 #[serde(rename = "capture_mode")]
17 pub capture_mode: PromptCaptureMode,
18
19 pub redaction: RedactionConfig,
21
22 pub exporter: ExporterConfig,
24
25 #[serde(default)]
27 pub capture_acknowledged: bool,
28
29 #[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 #[serde(default)]
73 pub policies: Vec<String>,
74}
75
76#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Default)]
77pub struct ExporterConfig {
78 #[serde(default)]
80 pub allowlist: Option<Vec<String>>,
81
82 #[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 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 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 return Err(
112 "OpenClaw: OTLP endpoint must use TLS (https://) when payload capture is enabled."
113 .to_string(),
114 );
115 }
116
117 if let Some(list) = &self.exporter.allowlist {
119 if !endpoint.is_empty() {
120 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 return Err("OpenClaw: An explicit 'exporter.allowlist' is required when payload capture is enabled.".to_string());
134 }
135
136 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 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 fn matches_allowlist(endpoint: &str, rule: &str) -> bool {
159 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 }
166 } else {
167 endpoint.split(':').next().map(|s| s.to_string())
169 };
170
171 let Some(host) = host_str else {
172 return false;
174 };
175
176 let host = host.to_lowercase();
178 let rule = rule.to_lowercase();
179
180 if rule.starts_with("*.") {
181 let suffix = &rule[1..]; 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 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 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 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 cfg.exporter.allowlist = Some(vec!["example.com".to_string(), "*.trusted.org".to_string()]);
236
237 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 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 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 unsafe {
260 std::env::remove_var("OTEL_EXPORTER_OTLP_ENDPOINT");
261 }
262 }
263
264 #[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 #[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 #[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 #[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}