use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[serde(default)]
pub struct OtelConfig {
pub genai_semconv_version: String,
pub semconv_stability: SemConvStability,
#[serde(rename = "capture_mode")]
pub capture_mode: PromptCaptureMode,
pub redaction: RedactionConfig,
pub exporter: ExporterConfig,
#[serde(default)]
pub capture_acknowledged: bool,
#[serde(default = "default_true")]
pub capture_requires_sampled_span: bool,
}
fn default_true() -> bool {
true
}
impl Default for OtelConfig {
fn default() -> Self {
Self {
genai_semconv_version: "1.28.0".to_string(),
semconv_stability: SemConvStability::default(),
capture_mode: PromptCaptureMode::default(),
redaction: RedactionConfig::default(),
exporter: ExporterConfig::default(),
capture_acknowledged: false,
capture_requires_sampled_span: true,
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Default)]
#[serde(rename_all = "snake_case")]
pub enum SemConvStability {
#[default]
StableOnly,
ExperimentalOptIn,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Default)]
#[serde(rename_all = "snake_case")]
pub enum PromptCaptureMode {
#[default]
Off,
RedactedInline,
BlobRef,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Default)]
pub struct RedactionConfig {
#[serde(default)]
pub policies: Vec<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Default)]
pub struct ExporterConfig {
#[serde(default)]
pub allowlist: Option<Vec<String>>,
#[serde(default)]
pub allow_localhost: bool,
}
impl OtelConfig {
pub fn validate(&self) -> Result<(), String> {
if matches!(self.capture_mode, PromptCaptureMode::Off) {
return Ok(());
}
if !self.capture_acknowledged {
return Err(
"OpenClaw: 'otel.capture_acknowledged' must be true when capture_mode is enabled."
.to_string(),
);
}
let endpoint = std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").unwrap_or_default();
if !endpoint.is_empty()
&& !endpoint.starts_with("https://")
&& !endpoint.starts_with("http://localhost")
{
return Err(
"OpenClaw: OTLP endpoint must use TLS (https://) when payload capture is enabled."
.to_string(),
);
}
if let Some(list) = &self.exporter.allowlist {
if !endpoint.is_empty() {
let allowed = list
.iter()
.any(|rule| Self::matches_allowlist(&endpoint, rule));
if !allowed {
return Err(format!(
"OpenClaw: OTLP endpoint '{}' is not in the explicit allowlist.",
endpoint
));
}
}
} else {
return Err("OpenClaw: An explicit 'exporter.allowlist' is required when payload capture is enabled.".to_string());
}
if !self.exporter.allow_localhost
&& (endpoint.contains("localhost")
|| endpoint.contains("127.0.0.1")
|| endpoint.contains("::1"))
{
return Err("OpenClaw: Export to localhost is blocked by default. Set 'exporter.allow_localhost = true' to enable.".to_string());
}
if matches!(self.capture_mode, PromptCaptureMode::BlobRef) {
let secret = std::env::var("ASSAY_ORG_SECRET").unwrap_or_default();
if secret.is_empty() || secret == "ephemeral-key" {
return Err("OpenClaw: BlobRef mode requires ASSAY_ORG_SECRET to be set (no ephemeral key).".to_string());
}
}
Ok(())
}
fn matches_allowlist(endpoint: &str, rule: &str) -> bool {
let host_str = if endpoint.contains("://") {
if let Ok(url) = url::Url::parse(endpoint) {
url.host_str().map(|h| h.to_string())
} else {
None }
} else {
endpoint.split(':').next().map(|s| s.to_string())
};
let Some(host) = host_str else {
return false;
};
let host = host.to_lowercase();
let rule = rule.to_lowercase();
if rule.starts_with("*.") {
let suffix = &rule[1..]; host.ends_with(suffix) && !host.strip_suffix(suffix).unwrap_or("").contains('.')
} else {
host == rule
}
}
}
#[cfg(test)]
#[allow(unsafe_code)]
mod tests {
use super::*;
use serial_test::serial;
#[test]
#[serial]
fn test_guardrails_validation() {
let mut cfg = OtelConfig {
capture_mode: PromptCaptureMode::RedactedInline,
capture_acknowledged: true,
exporter: ExporterConfig {
allowlist: None,
..Default::default()
},
..Default::default()
};
unsafe {
std::env::remove_var("OTEL_EXPORTER_OTLP_ENDPOINT");
}
let res = cfg.validate();
assert!(
res.is_err(),
"Should fail without allowlist when capture is on"
);
cfg.exporter.allowlist = Some(vec!["example.com".to_string()]);
unsafe {
std::env::set_var("OTEL_EXPORTER_OTLP_ENDPOINT", "http://example.com");
}
let res = cfg.validate();
assert!(res.is_err(), "Should fail HTTP endpoint");
unsafe {
std::env::set_var("OTEL_EXPORTER_OTLP_ENDPOINT", "https://example.com");
}
let res = cfg.validate();
assert!(res.is_ok(), "Should pass HTTPS + Allowlist");
cfg.exporter.allowlist = Some(vec!["example.com".to_string(), "*.trusted.org".to_string()]);
unsafe {
std::env::set_var(
"OTEL_EXPORTER_OTLP_ENDPOINT",
"https://example.com.attacker.tld",
);
}
assert!(cfg.validate().is_err(), "Must block suffix spoofing");
unsafe {
std::env::set_var("OTEL_EXPORTER_OTLP_ENDPOINT", "https://evilexample.com");
}
assert!(cfg.validate().is_err(), "Must block prefix spoofing");
unsafe {
std::env::set_var("OTEL_EXPORTER_OTLP_ENDPOINT", "https://api.trusted.org");
}
assert!(cfg.validate().is_ok(), "Must allow valid wildcard child");
unsafe {
std::env::remove_var("OTEL_EXPORTER_OTLP_ENDPOINT");
}
}
#[test]
#[serial]
fn test_allowlist_wildcard_mycorp_allowed_evil_denied() {
let cfg = OtelConfig {
capture_mode: PromptCaptureMode::BlobRef,
capture_acknowledged: true,
exporter: ExporterConfig {
allowlist: Some(vec!["*.mycorp.com".to_string()]),
..Default::default()
},
..Default::default()
};
unsafe {
std::env::set_var("ASSAY_ORG_SECRET", "test-secret");
}
unsafe {
std::env::set_var("OTEL_EXPORTER_OTLP_ENDPOINT", "https://otel.mycorp.com");
}
assert!(
cfg.validate().is_ok(),
"*.mycorp.com must allow https://otel.mycorp.com"
);
unsafe {
std::env::set_var("OTEL_EXPORTER_OTLP_ENDPOINT", "https://evilmycorp.com");
}
assert!(
cfg.validate().is_err(),
"*.mycorp.com must NOT allow https://evilmycorp.com (substring bypass)"
);
unsafe {
std::env::remove_var("OTEL_EXPORTER_OTLP_ENDPOINT");
}
unsafe {
std::env::remove_var("ASSAY_ORG_SECRET");
}
}
#[test]
#[serial]
fn test_allowlist_port_and_trailing_dot() {
let cfg = OtelConfig {
capture_mode: PromptCaptureMode::RedactedInline,
capture_acknowledged: true,
exporter: ExporterConfig {
allowlist: Some(vec!["otel.mycorp.com".to_string()]),
..Default::default()
},
..Default::default()
};
unsafe {
std::env::set_var("OTEL_EXPORTER_OTLP_ENDPOINT", "https://otel.mycorp.com:443");
}
assert!(
cfg.validate().is_ok(),
"Host with port must match by host only"
);
unsafe {
std::env::remove_var("OTEL_EXPORTER_OTLP_ENDPOINT");
}
}
#[test]
#[serial]
fn test_allow_localhost_default_deny_explicit_true_allowed() {
let mut cfg = OtelConfig {
capture_mode: PromptCaptureMode::BlobRef,
capture_acknowledged: true,
exporter: ExporterConfig {
allowlist: Some(vec!["127.0.0.1".to_string()]),
allow_localhost: false,
},
..Default::default()
};
unsafe {
std::env::set_var("ASSAY_ORG_SECRET", "test-secret");
}
unsafe {
std::env::set_var("OTEL_EXPORTER_OTLP_ENDPOINT", "https://127.0.0.1");
}
assert!(
cfg.validate().is_err(),
"allow_localhost=false must block localhost"
);
cfg.exporter.allow_localhost = true;
assert!(
cfg.validate().is_ok(),
"allow_localhost=true must allow when in allowlist"
);
unsafe {
std::env::remove_var("OTEL_EXPORTER_OTLP_ENDPOINT");
}
unsafe {
std::env::remove_var("ASSAY_ORG_SECRET");
}
}
#[test]
#[serial]
fn test_blob_ref_requires_assay_org_secret() {
let cfg = OtelConfig {
capture_mode: PromptCaptureMode::BlobRef,
capture_acknowledged: true,
exporter: ExporterConfig {
allowlist: Some(vec!["example.com".to_string()]),
..Default::default()
},
..Default::default()
};
unsafe {
std::env::remove_var("ASSAY_ORG_SECRET");
}
unsafe {
std::env::set_var("OTEL_EXPORTER_OTLP_ENDPOINT", "https://example.com");
}
assert!(
cfg.validate().is_err(),
"BlobRef must fail when ASSAY_ORG_SECRET unset"
);
unsafe {
std::env::set_var("ASSAY_ORG_SECRET", "ephemeral-key");
}
assert!(
cfg.validate().is_err(),
"BlobRef must fail when ASSAY_ORG_SECRET is ephemeral-key"
);
unsafe {
std::env::set_var("ASSAY_ORG_SECRET", "prod-secret-xyz");
}
assert!(
cfg.validate().is_ok(),
"BlobRef must pass when ASSAY_ORG_SECRET set"
);
unsafe {
std::env::remove_var("ASSAY_ORG_SECRET");
}
unsafe {
std::env::remove_var("OTEL_EXPORTER_OTLP_ENDPOINT");
}
}
}