Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T02:54:43.968Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

unicode_confusables.rs

245 lines · 9.3 KB · rust
1//! Detection of "surprising" Unicode characters in the domains and paths shown
2//! in sandbox privilege-escalation prompts.
3//!
4//! Homoglyph/confusable attacks (a Cyrillic `а` standing in for a Latin `a`),
5//! invisible characters (zero-width spaces), and bidirectional overrides can
6//! make a requested domain or path look like something it is not, tricking the
7//! user into granting access to the wrong target. Domains reach the prompt in
8//! Punycode (`xn--…`) ASCII form, so a lookalike host is decoded back to
9//! Unicode before scanning; paths are scanned as they are displayed.
10
11use unicode_script::UnicodeScript as _;
12
13/// Why a character in a domain or path is considered surprising.
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub enum SuspiciousKind {
16    /// A bidirectional control that can visually reorder surrounding text (for
17    /// example U+202E RIGHT-TO-LEFT OVERRIDE) — the classic "Trojan Source"
18    /// trick.
19    BidiControl,
20    /// A zero-width, invisible, or non-ASCII whitespace formatting character.
21    Invisible,
22    /// A visible non-ASCII character that can be confused with ASCII (a
23    /// homoglyph) or that mixes an unexpected script into otherwise-ASCII text.
24    Confusable,
25}
26
27/// A single surprising character discovered while scanning a domain or path.
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub struct SuspiciousChar {
30    pub character: char,
31    pub kind: SuspiciousKind,
32}
33
34impl SuspiciousChar {
35    /// A human-readable, one-line description for the approval banner, such as
36    /// `‘а’ (U+0430 Cyrillic)` or `U+202E right-to-left override`.
37    pub fn description(&self) -> String {
38        let codepoint = format!("U+{:04X}", self.character as u32);
39        match self.kind {
40            SuspiciousKind::Confusable => {
41                format!(
42                    "‘{}’ ({codepoint} {})",
43                    self.character,
44                    self.character.script().full_name()
45                )
46            }
47            // Bidi controls and invisible characters have no meaningful glyph to
48            // show (and printing them could itself reorder the banner text), so
49            // we render only the codepoint and a name.
50            SuspiciousKind::BidiControl | SuspiciousKind::Invisible => {
51                match well_known_name(self.character) {
52                    Some(name) => format!("{codepoint} {name}"),
53                    None => codepoint,
54                }
55            }
56        }
57    }
58}
59
60/// Scan a raw string for surprising Unicode characters, returning each distinct
61/// offending character once, in order of first appearance.
62pub fn scan(text: &str) -> Vec<SuspiciousChar> {
63    let mut result: Vec<SuspiciousChar> = Vec::new();
64    for character in text.chars() {
65        if character.is_ascii() {
66            continue;
67        }
68        if result.iter().any(|found| found.character == character) {
69            continue;
70        }
71        result.push(SuspiciousChar {
72            character,
73            kind: classify(character),
74        });
75    }
76    result
77}
78
79/// Scan a host for surprising characters, first decoding any IDN/Punycode
80/// (`xn--…`) labels back to Unicode so a lookalike domain that reaches us as
81/// ASCII is still caught. Returns the decoded (Unicode) host — which is what the
82/// banner shows the user — alongside the findings. When nothing is surprising
83/// the returned host equals the input.
84pub fn scan_host(host: &str) -> (String, Vec<SuspiciousChar>) {
85    // `domain_to_unicode` never fails destructively: on error it still returns a
86    // best-effort decoding, which is exactly what we want to scan and show.
87    let (decoded, _result) = idna::domain_to_unicode(host);
88    let findings = scan(&decoded);
89    (decoded, findings)
90}
91
92fn classify(character: char) -> SuspiciousKind {
93    if is_bidi_control(character) {
94        SuspiciousKind::BidiControl
95    } else if is_invisible(character) {
96        SuspiciousKind::Invisible
97    } else {
98        SuspiciousKind::Confusable
99    }
100}
101
102fn is_bidi_control(character: char) -> bool {
103    matches!(character,
104        '\u{061C}' // ARABIC LETTER MARK
105        | '\u{200E}' // LEFT-TO-RIGHT MARK
106        | '\u{200F}' // RIGHT-TO-LEFT MARK
107        | '\u{202A}'..='\u{202E}' // LRE, RLE, PDF, LRO, RLO
108        | '\u{2066}'..='\u{2069}' // LRI, RLI, FSI, PDI
109    )
110}
111
112fn is_invisible(character: char) -> bool {
113    matches!(character,
114        '\u{00AD}' // SOFT HYPHEN
115        | '\u{180E}' // MONGOLIAN VOWEL SEPARATOR
116        | '\u{200B}' // ZERO WIDTH SPACE
117        | '\u{200C}' // ZERO WIDTH NON-JOINER
118        | '\u{200D}' // ZERO WIDTH JOINER
119        | '\u{2060}' // WORD JOINER
120        | '\u{2061}'..='\u{2064}' // invisible math operators
121        | '\u{FEFF}' // ZERO WIDTH NO-BREAK SPACE (BOM)
122    ) || is_non_ascii_space(character)
123        // Any remaining control/format character (categories Cc/Cf) is
124        // invisible for our purposes.
125        || character.is_control()
126}
127
128fn is_non_ascii_space(character: char) -> bool {
129    matches!(
130        character,
131        '\u{00A0}' // NO-BREAK SPACE
132        | '\u{1680}' // OGHAM SPACE MARK
133        | '\u{2000}'
134            ..='\u{200A}' // EN QUAD … HAIR SPACE
135        | '\u{202F}' // NARROW NO-BREAK SPACE
136        | '\u{205F}' // MEDIUM MATHEMATICAL SPACE
137        | '\u{3000}' // IDEOGRAPHIC SPACE
138    )
139}
140
141/// Friendly names for the invisible/bidi characters most likely to show up in an
142/// attack, so the banner reads better than a bare codepoint.
143fn well_known_name(character: char) -> Option<&'static str> {
144    Some(match character {
145        '\u{00A0}' => "no-break space",
146        '\u{00AD}' => "soft hyphen",
147        '\u{061C}' => "arabic letter mark",
148        '\u{180E}' => "mongolian vowel separator",
149        '\u{200B}' => "zero-width space",
150        '\u{200C}' => "zero-width non-joiner",
151        '\u{200D}' => "zero-width joiner",
152        '\u{200E}' => "left-to-right mark",
153        '\u{200F}' => "right-to-left mark",
154        '\u{202A}' => "left-to-right embedding",
155        '\u{202B}' => "right-to-left embedding",
156        '\u{202C}' => "pop directional formatting",
157        '\u{202D}' => "left-to-right override",
158        '\u{202E}' => "right-to-left override",
159        '\u{2060}' => "word joiner",
160        '\u{2066}' => "left-to-right isolate",
161        '\u{2067}' => "right-to-left isolate",
162        '\u{2068}' => "first strong isolate",
163        '\u{2069}' => "pop directional isolate",
164        '\u{3000}' => "ideographic space",
165        '\u{FEFF}' => "zero-width no-break space",
166        _ => return None,
167    })
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn plain_ascii_is_never_flagged() {
176        assert!(scan("github.com").is_empty());
177        assert!(scan("/home/user/project/src/main.rs").is_empty());
178        assert!(scan("*.npmjs.org").is_empty());
179    }
180
181    #[test]
182    fn detects_cyrillic_homoglyph() {
183        // "gіthub.com" with a Cyrillic "і" (U+0456).
184        let findings = scan("g\u{0456}thub.com");
185        assert_eq!(findings.len(), 1);
186        assert_eq!(findings[0].character, '\u{0456}');
187        assert_eq!(findings[0].kind, SuspiciousKind::Confusable);
188        assert!(findings[0].description().contains("U+0456"));
189        assert!(findings[0].description().contains("Cyrillic"));
190    }
191
192    #[test]
193    fn detects_bidi_override() {
194        let findings = scan("safe\u{202E}txt.exe");
195        assert_eq!(findings.len(), 1);
196        assert_eq!(findings[0].kind, SuspiciousKind::BidiControl);
197        assert_eq!(findings[0].description(), "U+202E right-to-left override");
198    }
199
200    #[test]
201    fn detects_zero_width_space() {
202        let findings = scan("git\u{200B}hub.com");
203        assert_eq!(findings.len(), 1);
204        assert_eq!(findings[0].kind, SuspiciousKind::Invisible);
205        assert_eq!(findings[0].description(), "U+200B zero-width space");
206    }
207
208    #[test]
209    fn deduplicates_repeated_characters() {
210        // Two Cyrillic "а" (U+0430) should be reported once.
211        let findings = scan("\u{0430}bc\u{0430}");
212        assert_eq!(findings.len(), 1);
213    }
214
215    #[test]
216    fn scan_host_decodes_punycode_lookalike() {
217        // "аpple.com" (leading Cyrillic а, U+0430) encodes to this Punycode.
218        let (decoded, findings) = scan_host("xn--pple-43d.com");
219        assert_eq!(decoded, "\u{0430}pple.com");
220        assert_eq!(findings.len(), 1);
221        assert_eq!(findings[0].character, '\u{0430}');
222        assert_eq!(findings[0].kind, SuspiciousKind::Confusable);
223    }
224
225    #[test]
226    fn scan_host_leaves_plain_domains_alone() {
227        let (decoded, findings) = scan_host("github.com");
228        assert_eq!(decoded, "github.com");
229        assert!(findings.is_empty());
230    }
231
232    #[test]
233    fn scan_host_handles_wildcard_subdomain_patterns() {
234        // Host patterns can carry a leading `*.` wildcard; decoding must not
235        // choke on it, and a lookalike label behind it is still caught.
236        let (_decoded, findings) = scan_host("*.xn--pple-43d.com");
237        assert_eq!(findings.len(), 1);
238        assert_eq!(findings[0].character, '\u{0430}');
239
240        let (decoded, findings) = scan_host("*.github.com");
241        assert_eq!(decoded, "*.github.com");
242        assert!(findings.is_empty());
243    }
244}
245
Served at tenant.openagents/omega Member data and write actions are omitted.