Skip to main content

synaps_cli/events/
format.rs

1use super::types::Event;
2
3/// Strip any variation of </event> tags (case-insensitive, with whitespace)
4fn regex_strip_event_close(s: &str) -> String {
5    let mut result = String::with_capacity(s.len());
6    let chars: Vec<char> = s.chars().collect();
7    let lower_chars: Vec<char> = s.to_lowercase().chars().collect();
8    let mut i = 0;
9    while i < chars.len() {
10        // Check for </ event > pattern at current position
11        if i + 7 < chars.len() && lower_chars[i] == '<' && lower_chars[i + 1] == '/' {
12            // Scan for "event" after optional whitespace
13            let mut j = i + 2;
14            while j < chars.len() && lower_chars[j] == ' ' { j += 1; }
15            if j + 5 <= chars.len()
16                && lower_chars[j] == 'e'
17                && lower_chars[j + 1] == 'v'
18                && lower_chars[j + 2] == 'e'
19                && lower_chars[j + 3] == 'n'
20                && lower_chars[j + 4] == 't'
21            {
22                let mut k = j + 5;
23                while k < chars.len() && lower_chars[k] == ' ' { k += 1; }
24                if k < chars.len() && chars[k] == '>' {
25                    i = k + 1; // skip the entire closing tag
26                    continue;
27                }
28            }
29        }
30        result.push(chars[i]);
31        i += 1;
32    }
33    result
34}
35
36/// Format an event as a system message the agent can understand.
37/// Wrapped in XML tags to prevent prompt injection — the model should treat
38/// content inside <event> tags as DATA, not instructions.
39/// Example: `<event id="abc" type="alert" severity="high" source="uptime-kuma" channel="alerts">Jellyfin is DOWN.</event>`
40pub fn format_event_for_agent(event: &Event) -> String {
41    let sev = event
42        .content
43        .severity
44        .as_ref()
45        .map(|s| s.as_str())
46        .unwrap_or("medium");
47
48    let channel_attr = match &event.channel {
49        Some(ch) => format!(" channel=\"{}\"", ch.name.replace('"', "'")),
50        None => String::new(),
51    };
52
53    // Sanitize text — strip any closing </event> tags to prevent breakout
54    let safe_text = regex_strip_event_close(&event.content.text);
55    let safe_source = event.source.source_type.replace('"', "'");
56    let safe_content_type = event.content.content_type.replace('"', "'");
57
58    let mut out = format!(
59        "<event id=\"{}\" type=\"{}\" severity=\"{}\" source=\"{}\"{}>{}",
60        event.id, safe_content_type, sev, safe_source, channel_attr, safe_text
61    );
62
63    if let Some(data) = &event.content.data {
64        let data_str = serde_json::to_string(data).unwrap_or_default();
65        // Cap data size to prevent token abuse
66        let truncated: String = data_str.chars().take(1000).collect();
67        // Strip closing event tags from data (case-insensitive) to prevent breakout
68        let safe_data = regex_strip_event_close(&truncated);
69        out.push_str(&format!("\nData: {}", safe_data));
70    }
71
72    out.push_str("</event>");
73    out
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79    use crate::events::types::{EventChannel, Severity};
80    use serde_json::json;
81
82    #[test]
83    fn format_without_channel() {
84        let e = Event::simple("cli", "ping", Some(Severity::Low));
85        let s = format_event_for_agent(&e);
86        assert!(s.starts_with("<event id="));
87        assert!(s.contains("type=\"message\""));
88        assert!(s.contains("severity=\"low\""));
89        assert!(s.contains("source=\"cli\""));
90        assert!(s.contains("ping"));
91        assert!(s.ends_with("</event>"));
92    }
93
94    #[test]
95    fn format_with_channel() {
96        let mut e = Event::simple("uptime-kuma", "Jellyfin is DOWN. Status 503.", Some(Severity::High));
97        e.content.content_type = "alert".into();
98        e.channel = Some(EventChannel {
99            id: "1".into(),
100            name: "alerts".into(),
101        });
102        let s = format_event_for_agent(&e);
103        assert!(s.contains("source=\"uptime-kuma\""));
104        assert!(s.contains("channel=\"alerts\""));
105        assert!(s.contains("severity=\"high\""));
106        assert!(s.contains("Jellyfin is DOWN. Status 503."));
107        assert!(s.ends_with("</event>"));
108    }
109
110    #[test]
111    fn format_defaults_to_medium_when_no_severity() {
112        let e = Event::simple("cli", "hi", None);
113        let s = format_event_for_agent(&e);
114        assert!(s.contains("severity=\"medium\""));
115    }
116
117    #[test]
118    fn format_appends_data() {
119        let mut e = Event::simple("system", "boom", Some(Severity::Critical));
120        e.content.data = Some(json!({"code": 500}));
121        let s = format_event_for_agent(&e);
122        assert!(s.contains("boom"));
123        assert!(s.contains("\nData: "));
124        assert!(s.contains("\"code\":500"));
125    }
126}