@@ -211,3 +211,135 @@ def test_edge_cases(tool_name: str, is_valid: bool, expected_warning_fragment: s
211211 result = validate_tool_name (tool_name )
212212 assert result .is_valid is is_valid
213213 assert any (expected_warning_fragment in w for w in result .warnings )
214+
215+
216+ # ============================================================================
217+ # NEW TESTS: Unicode Homoglyph & Confusable Character Detection
218+ # ============================================================================
219+ # These tests verify that the validator detects Unicode homoglyphs
220+ # (characters that look identical but are different) that could be used
221+ # for tool name spoofing attacks.
222+
223+
224+ class TestUnicodeHomoglyphDetection :
225+ """Tests for detecting Unicode homoglyphs that visually impersonate ASCII."""
226+
227+ def test_cyrillic_a_rejected (self ) -> None :
228+ """Cyrillic А (U+0410) looks identical to Latin A but should be detected."""
229+ result = validate_tool_name ("tool_А" )
230+ assert not result .is_valid , "Cyrillic А should be rejected"
231+ assert any (
232+ "normalize" in w .lower () or "homoglyph" in w .lower ()
233+ for w in result .warnings
234+ ), f"Warning should mention normalization or homoglyph. Got: { result .warnings } "
235+
236+ def test_cyrillic_o_rejected (self ) -> None :
237+ """Cyrillic О (U+041E) looks identical to Latin O."""
238+ result = validate_tool_name ("tool_О" )
239+ assert not result .is_valid
240+
241+ def test_cyrillic_e_rejected (self ) -> None :
242+ """Cyrillic Е (U+0415) looks identical to Latin E."""
243+ result = validate_tool_name ("tool_Е" )
244+ assert not result .is_valid
245+
246+ def test_cyrillic_p_rejected (self ) -> None :
247+ """Cyrillic Р (U+0420) looks identical to Latin P."""
248+ result = validate_tool_name ("tool_Р" )
249+ assert not result .is_valid
250+
251+ def test_fullwidth_a_rejected (self ) -> None :
252+ """Fullwidth Latin а (U+FF41) looks like ASCII a."""
253+ result = validate_tool_name ("atool" )
254+ assert not result .is_valid
255+
256+ def test_fullwidth_mixed_rejected (self ) -> None :
257+ """Mix of fullwidth and ASCII should be detected."""
258+ result = validate_tool_name ("tool" ) # 'о' is fullwidth
259+ assert not result .is_valid
260+
261+ def test_greek_alpha_rejected (self ) -> None :
262+ """Greek Α (U+0391) looks like Latin A."""
263+ result = validate_tool_name ("tool_Α" )
264+ assert not result .is_valid
265+
266+ def test_rtl_override_rejected (self ) -> None :
267+ """Right-to-left override character (U+202E) should be rejected."""
268+ result = validate_tool_name ("tool_name" ) # RIGHT-TO-LEFT OVERRIDE
269+ assert not result .is_valid
270+ assert any (
271+ "directional" in w .lower () or "rtl" in w .lower ()
272+ for w in result .warnings
273+ ), f"Warning should mention directional formatting. Got: { result .warnings } "
274+
275+ def test_ltr_override_rejected (self ) -> None :
276+ """Left-to-right override character (U+202D) should be rejected."""
277+ result = validate_tool_name ("tool_name" ) # LEFT-TO-RIGHT OVERRIDE
278+ assert not result .is_valid
279+
280+
281+ class TestUnicodeNormalizationBypass :
282+ """Tests for Unicode normalization form attacks."""
283+
284+ def test_decomposed_unicode_detected (self ) -> None :
285+ """Decomposed Unicode should be normalized and detected."""
286+ import unicodedata
287+
288+ # 'café' in decomposed form (e + combining accent)
289+ decomposed = unicodedata .normalize ("NFD" , "café" )
290+ result = validate_tool_name (f"tool_{ decomposed } " )
291+ # Should either be rejected or warn
292+ assert (
293+ not result .is_valid
294+ or any ("normalize" in w .lower () for w in result .warnings )
295+ ), f"Decomposed forms should be detected. Got is_valid={ result .is_valid } , warnings={ result .warnings } "
296+
297+ def test_nfkc_normalization_detected (self ) -> None :
298+ """NFKC normalization changes should be detected."""
299+ import unicodedata
300+
301+ # Fullwidth characters normalize differently in NFKC
302+ original = "abc" # Fullwidth ASCII
303+ normalized = unicodedata .normalize ("NFKC" , original )
304+
305+ result = validate_tool_name (original )
306+ # Should detect that normalization would change this
307+ if original != normalized :
308+ assert (
309+ not result .is_valid
310+ or any ("normalize" in w .lower () for w in result .warnings )
311+ ), "NFKC normalization should be detected"
312+
313+
314+ class TestUnicodeBoundaryConditions :
315+ """Edge cases and boundary conditions."""
316+
317+ def test_zero_width_characters_rejected (self ) -> None :
318+ """Zero-width characters should be rejected."""
319+ result = validate_tool_name ("tool_name" ) # Zero-width space
320+ assert not result .is_valid
321+
322+
323+ class TestValidAsciiStillWorks :
324+ """Ensure valid ASCII tool names still pass after homoglyph detection added."""
325+
326+ def test_basic_ascii_passes (self ) -> None :
327+ """Standard ASCII tool names should still pass."""
328+ result = validate_tool_name ("verify_user" )
329+ assert result .is_valid
330+
331+ def test_numbers_pass (self ) -> None :
332+ result = validate_tool_name ("tool_123" )
333+ assert result .is_valid
334+
335+ def test_dashes_underscores_pass (self ) -> None :
336+ result = validate_tool_name ("my-tool_name-v2" )
337+ assert result .is_valid
338+
339+ def test_dots_pass (self ) -> None :
340+ result = validate_tool_name ("tool.extension" )
341+ assert result .is_valid
342+
343+ def test_long_valid_name_passes (self ) -> None :
344+ result = validate_tool_name ("a" * 128 ) # Max length, all valid
345+ assert result .is_valid
0 commit comments