Skip to repository content245 lines · 9.3 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T02:54:43.968Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
unicode_confusables.rs
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