zjcode 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. deepagents_code/__init__.py +42 -0
  2. deepagents_code/__main__.py +6 -0
  3. deepagents_code/_ask_user_types.py +90 -0
  4. deepagents_code/_cli_context.py +96 -0
  5. deepagents_code/_constants.py +41 -0
  6. deepagents_code/_debug.py +148 -0
  7. deepagents_code/_debug_buffer.py +204 -0
  8. deepagents_code/_env_vars.py +411 -0
  9. deepagents_code/_fake_models.py +66 -0
  10. deepagents_code/_git.py +521 -0
  11. deepagents_code/_paths.py +69 -0
  12. deepagents_code/_server_config.py +576 -0
  13. deepagents_code/_session_stats.py +235 -0
  14. deepagents_code/_startup_error.py +45 -0
  15. deepagents_code/_testing_models.py +305 -0
  16. deepagents_code/_textual_patches.py +420 -0
  17. deepagents_code/_tool_stream.py +694 -0
  18. deepagents_code/_version.py +46 -0
  19. deepagents_code/agent.py +1976 -0
  20. deepagents_code/app.py +19239 -0
  21. deepagents_code/app.tcss +438 -0
  22. deepagents_code/approval_mode.py +131 -0
  23. deepagents_code/ask_user.py +306 -0
  24. deepagents_code/auth_display.py +185 -0
  25. deepagents_code/auth_store.py +545 -0
  26. deepagents_code/built_in_skills/__init__.py +5 -0
  27. deepagents_code/built_in_skills/remember/SKILL.md +118 -0
  28. deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
  29. deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
  30. deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
  31. deepagents_code/client/__init__.py +1 -0
  32. deepagents_code/client/commands/__init__.py +1 -0
  33. deepagents_code/client/commands/auth.py +541 -0
  34. deepagents_code/client/commands/config.py +714 -0
  35. deepagents_code/client/commands/mcp.py +250 -0
  36. deepagents_code/client/commands/tools.py +416 -0
  37. deepagents_code/client/launch/__init__.py +1 -0
  38. deepagents_code/client/launch/server.py +978 -0
  39. deepagents_code/client/launch/server_manager.py +540 -0
  40. deepagents_code/client/non_interactive.py +1758 -0
  41. deepagents_code/client/remote_client.py +794 -0
  42. deepagents_code/clipboard.py +217 -0
  43. deepagents_code/command_registry.py +450 -0
  44. deepagents_code/config.py +4829 -0
  45. deepagents_code/config_manifest.py +1451 -0
  46. deepagents_code/configurable_model.py +577 -0
  47. deepagents_code/default_agent_prompt.md +12 -0
  48. deepagents_code/doctor.py +559 -0
  49. deepagents_code/editor.py +142 -0
  50. deepagents_code/event_bus.py +411 -0
  51. deepagents_code/extras_info.py +661 -0
  52. deepagents_code/file_ops.py +576 -0
  53. deepagents_code/formatting.py +135 -0
  54. deepagents_code/goal_rubric.py +497 -0
  55. deepagents_code/goal_tools.py +459 -0
  56. deepagents_code/hooks.py +348 -0
  57. deepagents_code/input.py +1041 -0
  58. deepagents_code/integrations/__init__.py +1 -0
  59. deepagents_code/integrations/openai_codex.py +551 -0
  60. deepagents_code/integrations/sandbox_config.py +198 -0
  61. deepagents_code/integrations/sandbox_factory.py +1124 -0
  62. deepagents_code/integrations/sandbox_provider.py +137 -0
  63. deepagents_code/integrations/sandbox_registry.py +350 -0
  64. deepagents_code/iterm_cursor_guide.py +176 -0
  65. deepagents_code/local_context.py +926 -0
  66. deepagents_code/main.py +3985 -0
  67. deepagents_code/managed_tools.py +642 -0
  68. deepagents_code/mcp_auth.py +1950 -0
  69. deepagents_code/mcp_config.py +176 -0
  70. deepagents_code/mcp_disabled.py +212 -0
  71. deepagents_code/mcp_login_service.py +281 -0
  72. deepagents_code/mcp_oauth_ui.py +199 -0
  73. deepagents_code/mcp_providers/__init__.py +23 -0
  74. deepagents_code/mcp_providers/_registry.py +39 -0
  75. deepagents_code/mcp_providers/base.py +133 -0
  76. deepagents_code/mcp_providers/github.py +102 -0
  77. deepagents_code/mcp_providers/slack.py +175 -0
  78. deepagents_code/mcp_tools.py +2427 -0
  79. deepagents_code/mcp_trust.py +207 -0
  80. deepagents_code/media_utils.py +826 -0
  81. deepagents_code/memory_guard.py +474 -0
  82. deepagents_code/model_config.py +4156 -0
  83. deepagents_code/notifications.py +247 -0
  84. deepagents_code/offload.py +402 -0
  85. deepagents_code/onboarding.py +223 -0
  86. deepagents_code/output.py +69 -0
  87. deepagents_code/paste_collapse.py +103 -0
  88. deepagents_code/project_utils.py +231 -0
  89. deepagents_code/py.typed +0 -0
  90. deepagents_code/reasoning_effort.py +641 -0
  91. deepagents_code/reliable_rubric.py +97 -0
  92. deepagents_code/resume_state.py +237 -0
  93. deepagents_code/server_graph.py +310 -0
  94. deepagents_code/session_end_summary.py +329 -0
  95. deepagents_code/sessions.py +1716 -0
  96. deepagents_code/skills/__init__.py +18 -0
  97. deepagents_code/skills/commands.py +1252 -0
  98. deepagents_code/skills/invocation.py +112 -0
  99. deepagents_code/skills/load.py +196 -0
  100. deepagents_code/skills/trust.py +547 -0
  101. deepagents_code/state_migration.py +136 -0
  102. deepagents_code/subagents.py +278 -0
  103. deepagents_code/system_prompt.md +204 -0
  104. deepagents_code/terminal_capabilities.py +115 -0
  105. deepagents_code/terminal_escape.py +287 -0
  106. deepagents_code/theme.py +891 -0
  107. deepagents_code/todo_list_prompt.md +12 -0
  108. deepagents_code/tool_catalog.py +509 -0
  109. deepagents_code/tool_display.py +367 -0
  110. deepagents_code/tools.py +516 -0
  111. deepagents_code/tui/__init__.py +1 -0
  112. deepagents_code/tui/textual_adapter.py +2553 -0
  113. deepagents_code/tui/widgets/__init__.py +9 -0
  114. deepagents_code/tui/widgets/_js_eval_display.py +139 -0
  115. deepagents_code/tui/widgets/_links.py +261 -0
  116. deepagents_code/tui/widgets/agent_selector.py +420 -0
  117. deepagents_code/tui/widgets/approval.py +602 -0
  118. deepagents_code/tui/widgets/ask_user.py +515 -0
  119. deepagents_code/tui/widgets/auth.py +1997 -0
  120. deepagents_code/tui/widgets/autocomplete.py +890 -0
  121. deepagents_code/tui/widgets/chat_input.py +3181 -0
  122. deepagents_code/tui/widgets/codex_auth.py +452 -0
  123. deepagents_code/tui/widgets/cwd_switch.py +242 -0
  124. deepagents_code/tui/widgets/debug_console.py +868 -0
  125. deepagents_code/tui/widgets/diff.py +252 -0
  126. deepagents_code/tui/widgets/effort_selector.py +189 -0
  127. deepagents_code/tui/widgets/goal_review.py +390 -0
  128. deepagents_code/tui/widgets/goal_status.py +50 -0
  129. deepagents_code/tui/widgets/history.py +194 -0
  130. deepagents_code/tui/widgets/install_confirm.py +248 -0
  131. deepagents_code/tui/widgets/launch_init.py +482 -0
  132. deepagents_code/tui/widgets/loading.py +227 -0
  133. deepagents_code/tui/widgets/mcp_login.py +539 -0
  134. deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
  135. deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
  136. deepagents_code/tui/widgets/message_store.py +999 -0
  137. deepagents_code/tui/widgets/messages.py +3846 -0
  138. deepagents_code/tui/widgets/model_selector.py +2343 -0
  139. deepagents_code/tui/widgets/notification_center.py +456 -0
  140. deepagents_code/tui/widgets/notification_detail.py +257 -0
  141. deepagents_code/tui/widgets/notification_settings.py +170 -0
  142. deepagents_code/tui/widgets/restart_prompt.py +158 -0
  143. deepagents_code/tui/widgets/skill_trust.py +131 -0
  144. deepagents_code/tui/widgets/startup_tip.py +91 -0
  145. deepagents_code/tui/widgets/status.py +781 -0
  146. deepagents_code/tui/widgets/subagent_panel.py +969 -0
  147. deepagents_code/tui/widgets/theme_selector.py +401 -0
  148. deepagents_code/tui/widgets/thread_selector.py +2564 -0
  149. deepagents_code/tui/widgets/tool_renderers.py +190 -0
  150. deepagents_code/tui/widgets/tool_widgets.py +304 -0
  151. deepagents_code/tui/widgets/update_available.py +311 -0
  152. deepagents_code/tui/widgets/update_confirm.py +184 -0
  153. deepagents_code/tui/widgets/update_progress.py +327 -0
  154. deepagents_code/tui/widgets/welcome.py +508 -0
  155. deepagents_code/turn_end_summary.py +522 -0
  156. deepagents_code/ui.py +858 -0
  157. deepagents_code/unicode_security.py +563 -0
  158. deepagents_code/update_check.py +3124 -0
  159. zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
  160. zjcode-0.0.1.dist-info/METADATA +220 -0
  161. zjcode-0.0.1.dist-info/RECORD +163 -0
  162. zjcode-0.0.1.dist-info/WHEEL +4 -0
  163. zjcode-0.0.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,563 @@
1
+ """Unicode security helpers for deceptive text and URL checks.
2
+
3
+ This module is intentionally lightweight so it can be imported in display and
4
+ approval paths without affecting startup performance.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ipaddress
10
+ import unicodedata
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+ from urllib.parse import urlparse
14
+
15
+ _DANGEROUS_CODEPOINTS: frozenset[int] = frozenset(
16
+ {
17
+ # BiDi directional formatting controls (embeddings, overrides, pop)
18
+ *range(0x202A, 0x202F),
19
+ # BiDi isolate controls (isolates, pop isolate)
20
+ *range(0x2066, 0x206A),
21
+ # Zero-width and invisible formatting controls
22
+ 0x200B, # ZERO WIDTH SPACE
23
+ 0x200C, # ZERO WIDTH NON-JOINER
24
+ 0x200D, # ZERO WIDTH JOINER
25
+ 0x200E, # LEFT-TO-RIGHT MARK
26
+ 0x200F, # RIGHT-TO-LEFT MARK
27
+ 0x2060, # WORD JOINER
28
+ 0xFEFF, # ZERO WIDTH NO-BREAK SPACE / BOM
29
+ # Other commonly abused invisible controls
30
+ 0x00AD, # SOFT HYPHEN
31
+ 0x034F, # COMBINING GRAPHEME JOINER
32
+ 0x115F, # HANGUL CHOSEONG FILLER
33
+ 0x1160, # HANGUL JUNGSEONG FILLER
34
+ }
35
+ )
36
+ """Code points that should be treated as deceptive/invisible for agent safety."""
37
+
38
+ _DANGEROUS_CHARACTERS: frozenset[str] = frozenset(
39
+ chr(codepoint) for codepoint in _DANGEROUS_CODEPOINTS
40
+ )
41
+
42
+ # Minimal high-risk confusables for warn-level detection.
43
+ CONFUSABLES: dict[str, str] = {
44
+ # Cyrillic
45
+ "\u0430": "a", # CYRILLIC SMALL LETTER A
46
+ "\u0435": "e", # CYRILLIC SMALL LETTER IE
47
+ "\u043e": "o", # CYRILLIC SMALL LETTER O
48
+ "\u0440": "p", # CYRILLIC SMALL LETTER ER
49
+ "\u0441": "c", # CYRILLIC SMALL LETTER ES
50
+ "\u0443": "y", # CYRILLIC SMALL LETTER U
51
+ "\u0445": "x", # CYRILLIC SMALL LETTER HA
52
+ "\u043d": "h", # CYRILLIC SMALL LETTER EN
53
+ "\u0456": "i", # CYRILLIC SMALL LETTER BYELORUSSIAN-UKRAINIAN I
54
+ "\u0458": "j", # CYRILLIC SMALL LETTER JE
55
+ "\u043a": "k", # CYRILLIC SMALL LETTER KA
56
+ "\u0455": "s", # CYRILLIC SMALL LETTER DZE
57
+ # Greek
58
+ "\u03b1": "a", # GREEK SMALL LETTER ALPHA
59
+ "\u03b5": "e", # GREEK SMALL LETTER EPSILON
60
+ "\u03bf": "o", # GREEK SMALL LETTER OMICRON
61
+ "\u03c1": "p", # GREEK SMALL LETTER RHO
62
+ "\u03c7": "x", # GREEK SMALL LETTER CHI
63
+ "\u03ba": "k", # GREEK SMALL LETTER KAPPA
64
+ "\u03bd": "v", # GREEK SMALL LETTER NU
65
+ "\u03c4": "t", # GREEK SMALL LETTER TAU
66
+ # Armenian
67
+ "\u0570": "h", # ARMENIAN SMALL LETTER HO
68
+ "\u0578": "n", # ARMENIAN SMALL LETTER VO
69
+ "\u057d": "u", # ARMENIAN SMALL LETTER SEH
70
+ # Fullwidth Latin
71
+ "\uff41": "a", # FULLWIDTH LATIN SMALL LETTER A
72
+ "\uff45": "e", # FULLWIDTH LATIN SMALL LETTER E
73
+ "\uff4f": "o", # FULLWIDTH LATIN SMALL LETTER O
74
+ }
75
+
76
+ URL_ARG_KEYS: frozenset[str] = frozenset(
77
+ {"url", "uri", "href", "link", "base_url", "endpoint"}
78
+ )
79
+ """Argument key names that likely contain URLs and should be safety-checked."""
80
+
81
+ _URL_SAFE_LOCAL_HOSTS: frozenset[str] = frozenset({"localhost"})
82
+
83
+
84
+ @dataclass(frozen=True, slots=True)
85
+ class UnicodeIssue:
86
+ """A dangerous Unicode character found in text.
87
+
88
+ Attributes:
89
+ position: Zero-based index in the original string.
90
+ character: The single raw character found in the input.
91
+ codepoint: Uppercase code point string like `U+202E`.
92
+ name: Unicode character name.
93
+ """
94
+
95
+ position: int
96
+ character: str
97
+ codepoint: str
98
+ name: str
99
+
100
+ def __post_init__(self) -> None: # noqa: D105
101
+ if len(self.character) != 1:
102
+ msg = (
103
+ "character must be a single code point, "
104
+ f"got length {len(self.character)}"
105
+ )
106
+ raise ValueError(msg)
107
+ expected = f"U+{ord(self.character):04X}"
108
+ if self.codepoint != expected:
109
+ msg = (
110
+ f"codepoint {self.codepoint!r} does not match "
111
+ f"character (expected {expected})"
112
+ )
113
+ raise ValueError(msg)
114
+
115
+
116
+ @dataclass(frozen=True, slots=True)
117
+ class UrlSafetyResult:
118
+ """Safety analysis output for a URL string.
119
+
120
+ A result may have `safe=True` with non-empty `warnings` when
121
+ informational warnings (e.g. punycode decoding) are present without
122
+ suspicious patterns.
123
+
124
+ Attributes:
125
+ safe: `True` if no suspicious patterns were found.
126
+ decoded_domain: Punycode-decoded hostname when it differs from the
127
+ original hostname.
128
+
129
+ `None` when unchanged or no hostname exists.
130
+ warnings: Human-readable warning strings (immutable).
131
+ issues: Dangerous Unicode issues found in the full URL (immutable).
132
+ """
133
+
134
+ safe: bool
135
+ decoded_domain: str | None
136
+ warnings: tuple[str, ...]
137
+ issues: tuple[UnicodeIssue, ...]
138
+
139
+
140
+ def detect_dangerous_unicode(text: str) -> list[UnicodeIssue]:
141
+ """Detect deceptive or hidden Unicode code points in text.
142
+
143
+ Args:
144
+ text: Input text to inspect.
145
+
146
+ Returns:
147
+ A list of `UnicodeIssue` entries in source order.
148
+ """
149
+ issues: list[UnicodeIssue] = []
150
+ for position, character in enumerate(text):
151
+ if character not in _DANGEROUS_CHARACTERS:
152
+ continue
153
+ issues.append(
154
+ UnicodeIssue(
155
+ position=position,
156
+ character=character,
157
+ codepoint=_format_codepoint(character),
158
+ name=_unicode_name(character),
159
+ )
160
+ )
161
+ return issues
162
+
163
+
164
+ def strip_dangerous_unicode(text: str) -> str:
165
+ """Remove known dangerous/invisible Unicode characters from text.
166
+
167
+ Args:
168
+ text: Input text to sanitize.
169
+
170
+ Returns:
171
+ Sanitized text with dangerous characters removed.
172
+ """
173
+ return "".join(ch for ch in text if ch not in _DANGEROUS_CHARACTERS)
174
+
175
+
176
+ def sanitize_control_chars(
177
+ text: str,
178
+ *,
179
+ keep_newlines: bool = False,
180
+ collapse_whitespace: bool = True,
181
+ max_length: int | None = None,
182
+ ) -> str:
183
+ """Neutralize control characters and deceptive Unicode in untrusted text.
184
+
185
+ Untrusted strings (MCP server errors, config-file contents, tool output)
186
+ can carry ANSI escape sequences, other control characters, or invisible
187
+ Unicode that corrupts the terminal, breaks out of a layout, or injects fake
188
+ lines into logs and prompts. This first removes the invisible/bidi code
189
+ points flagged by `strip_dangerous_unicode`, then replaces every remaining
190
+ Unicode "Other" (control/format) character with a space.
191
+
192
+ Args:
193
+ text: Untrusted text to sanitize.
194
+ keep_newlines: When `True`, newlines survive so multiline, scrollable
195
+ surfaces keep their line structure; otherwise newlines are
196
+ flattened to spaces along with the other control characters.
197
+ collapse_whitespace: When `True`, runs of whitespace are collapsed to a
198
+ single space and surrounding whitespace is stripped. With
199
+ `keep_newlines`, collapsing is applied per line so line breaks are
200
+ preserved.
201
+ max_length: When set, truncate to at most this many characters,
202
+ replacing the final character with an ellipsis.
203
+
204
+ Returns:
205
+ Sanitized text safe to embed in terminal output, markup substitutions,
206
+ logs, or prompts.
207
+ """
208
+ allowed = {" ", "\n"} if keep_newlines else {" "}
209
+ cleaned = "".join(
210
+ ch if ch in allowed or not unicodedata.category(ch).startswith("C") else " "
211
+ for ch in strip_dangerous_unicode(text)
212
+ )
213
+ if collapse_whitespace:
214
+ if keep_newlines:
215
+ cleaned = "\n".join(" ".join(line.split()) for line in cleaned.split("\n"))
216
+ else:
217
+ cleaned = " ".join(cleaned.split())
218
+ if max_length is not None and len(cleaned) > max_length:
219
+ cleaned = cleaned[: max_length - 1].rstrip() + "…"
220
+ return cleaned
221
+
222
+
223
+ def render_with_unicode_markers(text: str) -> str:
224
+ """Render hidden Unicode characters as explicit markers.
225
+
226
+ Example output: `abc<U+202E RIGHT-TO-LEFT OVERRIDE>def`.
227
+
228
+ Args:
229
+ text: Input text to render.
230
+
231
+ Returns:
232
+ Text where dangerous characters are replaced with visible markers.
233
+ """
234
+ rendered_parts: list[str] = []
235
+ for character in text:
236
+ if character not in _DANGEROUS_CHARACTERS:
237
+ rendered_parts.append(character)
238
+ continue
239
+ rendered_parts.append(
240
+ f"<{_format_codepoint(character)} {_unicode_name(character)}>"
241
+ )
242
+ return "".join(rendered_parts)
243
+
244
+
245
+ def summarize_issues(issues: list[UnicodeIssue], *, max_items: int = 3) -> str:
246
+ """Summarize Unicode issues for warning messages.
247
+
248
+ Deduplicates by code point. When more than *max_items* unique entries exist,
249
+ the summary is truncated with a `+N more entries` suffix.
250
+
251
+ Args:
252
+ issues: A list of detected issues.
253
+ max_items: Max unique code points to include in output.
254
+
255
+ Returns:
256
+ Comma-separated summary, e.g.
257
+ `U+202E RIGHT-TO-LEFT OVERRIDE, U+200B ZERO WIDTH SPACE`.
258
+ """
259
+ unique_entries: list[str] = []
260
+ seen: set[str] = set()
261
+ for issue in issues:
262
+ entry = f"{issue.codepoint} {issue.name}"
263
+ if entry in seen:
264
+ continue
265
+ seen.add(entry)
266
+ unique_entries.append(entry)
267
+
268
+ if len(unique_entries) <= max_items:
269
+ return ", ".join(unique_entries)
270
+
271
+ displayed = ", ".join(unique_entries[:max_items])
272
+ remainder = len(unique_entries) - max_items
273
+ suffix = "entry" if remainder == 1 else "entries"
274
+ return f"{displayed}, +{remainder} more {suffix}"
275
+
276
+
277
+ def format_warning_detail(warnings: tuple[str, ...], *, max_shown: int = 2) -> str:
278
+ """Join safety warnings into a display string with overflow indicator.
279
+
280
+ Args:
281
+ warnings: Warning strings from a `UrlSafetyResult`.
282
+ max_shown: Maximum warnings to include before truncating.
283
+
284
+ Returns:
285
+ Semicolon-separated detail string, e.g. `'warn1; warn2; +1 more'`.
286
+ """
287
+ shown = warnings[:max_shown]
288
+ detail = "; ".join(shown)
289
+ remaining = len(warnings) - max_shown
290
+ if remaining > 0:
291
+ detail += f"; +{remaining} more"
292
+ return detail
293
+
294
+
295
+ def check_url_safety(url: str) -> UrlSafetyResult:
296
+ """Check a URL for suspicious Unicode and domain spoofing patterns.
297
+
298
+ Args:
299
+ url: URL string to inspect.
300
+
301
+ Returns:
302
+ `UrlSafetyResult` including decoded domain and warning details.
303
+ """
304
+ warnings: list[str] = []
305
+ suspicious = False
306
+
307
+ issues = detect_dangerous_unicode(url)
308
+ if issues:
309
+ suspicious = True
310
+ warnings.append(
311
+ f"URL contains hidden Unicode characters ({summarize_issues(issues)})"
312
+ )
313
+
314
+ parsed = urlparse(url)
315
+ hostname = parsed.hostname
316
+ if not hostname:
317
+ return UrlSafetyResult(
318
+ safe=not suspicious,
319
+ decoded_domain=None,
320
+ warnings=tuple(warnings),
321
+ issues=tuple(issues),
322
+ )
323
+
324
+ decoded_hostname, failed_punycode = _decode_hostname(hostname)
325
+ decoded_domain = decoded_hostname if decoded_hostname != hostname else None
326
+ if decoded_domain:
327
+ warnings.append(f"Punycode domain decodes to '{decoded_domain}'")
328
+ if failed_punycode:
329
+ suspicious = True
330
+ labels = ", ".join(failed_punycode)
331
+ warnings.append(f"Punycode label(s) could not be decoded: {labels}")
332
+
333
+ if _is_local_or_ip_hostname(decoded_hostname):
334
+ return UrlSafetyResult(
335
+ safe=not suspicious,
336
+ decoded_domain=decoded_domain,
337
+ warnings=tuple(warnings),
338
+ issues=tuple(issues),
339
+ )
340
+
341
+ for label in _split_hostname_labels(decoded_hostname):
342
+ scripts = _scripts_in_label(label)
343
+ if len(scripts) > 1:
344
+ suspicious = True
345
+ script_names = ", ".join(sorted(scripts))
346
+ warnings.append(f"Domain label '{label}' mixes scripts ({script_names})")
347
+
348
+ if _label_has_suspicious_confusable_mix(label):
349
+ suspicious = True
350
+ warnings.append(
351
+ f"Domain label '{label}' contains confusable Unicode characters"
352
+ )
353
+
354
+ return UrlSafetyResult(
355
+ safe=not suspicious,
356
+ decoded_domain=decoded_domain,
357
+ warnings=tuple(warnings),
358
+ issues=tuple(issues),
359
+ )
360
+
361
+
362
+ def _decode_hostname(hostname: str) -> tuple[str, list[str]]:
363
+ """Decode `xn--` punycode labels into Unicode labels when possible.
364
+
365
+ Returns:
366
+ Tuple of (decoded hostname, list of labels that failed to decode).
367
+ """
368
+ decoded_labels: list[str] = []
369
+ failed_labels: list[str] = []
370
+ for label in _split_hostname_labels(hostname):
371
+ if label.startswith("xn--"):
372
+ try:
373
+ decoded_labels.append(label.encode("ascii").decode("idna"))
374
+ except UnicodeError:
375
+ decoded_labels.append(label)
376
+ failed_labels.append(label)
377
+ continue
378
+ decoded_labels.append(label)
379
+ return ".".join(decoded_labels), failed_labels
380
+
381
+
382
+ def _split_hostname_labels(hostname: str) -> list[str]:
383
+ """Split a hostname into non-empty labels.
384
+
385
+ Returns:
386
+ Hostname labels without empty entries.
387
+ """
388
+ return [label for label in hostname.split(".") if label]
389
+
390
+
391
+ def _is_local_or_ip_hostname(hostname: str) -> bool:
392
+ """Return whether hostname is localhost or an IP address literal.
393
+
394
+ Returns:
395
+ `True` when hostname is localhost or an IP literal, else `False`.
396
+ """
397
+ host = hostname.strip().rstrip(".")
398
+ if not host:
399
+ return False
400
+
401
+ if host.lower() in _URL_SAFE_LOCAL_HOSTS:
402
+ return True
403
+
404
+ try:
405
+ ipaddress.ip_address(host)
406
+ except ValueError:
407
+ return False
408
+ return True
409
+
410
+
411
+ def _scripts_in_label(label: str) -> set[str]:
412
+ """Collect non-common scripts used by a domain label.
413
+
414
+ Returns:
415
+ Set of script names used by the label, excluding common/inherited.
416
+ """
417
+ scripts: set[str] = set()
418
+ for character in label:
419
+ script = _char_script(character)
420
+ if script in {"Common", "Inherited"}:
421
+ continue
422
+ scripts.add(script)
423
+ return scripts
424
+
425
+
426
+ def _label_has_suspicious_confusable_mix(label: str) -> bool:
427
+ """Return whether a label has likely deceptive confusable characters.
428
+
429
+ Only flags labels that mix multiple scripts while containing confusable
430
+ characters. Single-script labels (even with confusables) are not flagged
431
+ because they represent legitimate use of that script.
432
+
433
+ Returns:
434
+ `True` when the label mixes scripts and contains confusable characters.
435
+ """
436
+ if not any(character in CONFUSABLES for character in label):
437
+ return False
438
+
439
+ scripts = _scripts_in_label(label)
440
+ return len(scripts) > 1
441
+
442
+
443
+ def _char_script(character: str) -> str:
444
+ """Classify a character into a coarse Unicode script bucket.
445
+
446
+ Returns:
447
+ One of: `'Fullwidth'`, `'Latin'`, `'Cyrillic'`, `'Greek'`, `'Armenian'`,
448
+ `'EastAsian'`, `'Inherited'`, `'Common'`, or `'Other'`.
449
+ """
450
+ name = unicodedata.name(character, "")
451
+ category = unicodedata.category(character)
452
+
453
+ if "FULLWIDTH LATIN" in name:
454
+ return "Fullwidth"
455
+ if "LATIN" in name:
456
+ return "Latin"
457
+ if "CYRILLIC" in name:
458
+ return "Cyrillic"
459
+ if "GREEK" in name:
460
+ return "Greek"
461
+ if "ARMENIAN" in name:
462
+ return "Armenian"
463
+ if any(
464
+ token in name
465
+ for token in (
466
+ "CJK",
467
+ "HIRAGANA",
468
+ "KATAKANA",
469
+ "HANGUL",
470
+ "BOPOMOFO",
471
+ "IDEOGRAPHIC",
472
+ )
473
+ ):
474
+ return "EastAsian"
475
+
476
+ if category.startswith("M"):
477
+ return "Inherited"
478
+ if category[0] in {"N", "P", "S", "Z", "C"}:
479
+ return "Common"
480
+
481
+ return "Other"
482
+
483
+
484
+ def _format_codepoint(character: str) -> str:
485
+ """Format character code point in `U+XXXX` uppercase form.
486
+
487
+ Returns:
488
+ Uppercase `U+XXXX` codepoint string.
489
+ """
490
+ return f"U+{ord(character):04X}"
491
+
492
+
493
+ def _unicode_name(character: str) -> str:
494
+ """Return a stable Unicode name with a fallback for unknown code points.
495
+
496
+ Returns:
497
+ Unicode name string for the character.
498
+ """
499
+ return unicodedata.name(character, "UNKNOWN CHARACTER")
500
+
501
+
502
+ # ---------------------------------------------------------------------------
503
+ # Shared helpers for recursive argument inspection
504
+ # ---------------------------------------------------------------------------
505
+
506
+
507
+ def iter_string_values(
508
+ data: dict[str, Any],
509
+ *,
510
+ prefix: str = "",
511
+ ) -> list[tuple[str, str]]:
512
+ """Flatten nested dict/list structures into key-path/string pairs.
513
+
514
+ Returns:
515
+ List of `(path, value)` tuples for all string leaves.
516
+ """
517
+ values: list[tuple[str, str]] = []
518
+ for key, value in data.items():
519
+ key_path = f"{prefix}.{key}" if prefix else key
520
+ if isinstance(value, str):
521
+ values.append((key_path, value))
522
+ continue
523
+ if isinstance(value, dict):
524
+ values.extend(iter_string_values(value, prefix=key_path))
525
+ continue
526
+ if isinstance(value, list):
527
+ values.extend(_iter_string_values_from_list(value, prefix=key_path))
528
+ return values
529
+
530
+
531
+ def _iter_string_values_from_list(
532
+ values: list[Any],
533
+ *,
534
+ prefix: str,
535
+ ) -> list[tuple[str, str]]:
536
+ """Flatten nested list values into key-path/string pairs.
537
+
538
+ Returns:
539
+ List of `(path, value)` tuples for all string leaves.
540
+ """
541
+ entries: list[tuple[str, str]] = []
542
+ for index, value in enumerate(values):
543
+ key_path = f"{prefix}[{index}]"
544
+ if isinstance(value, str):
545
+ entries.append((key_path, value))
546
+ continue
547
+ if isinstance(value, dict):
548
+ entries.extend(iter_string_values(value, prefix=key_path))
549
+ continue
550
+ if isinstance(value, list):
551
+ entries.extend(_iter_string_values_from_list(value, prefix=key_path))
552
+ return entries
553
+
554
+
555
+ def looks_like_url_key(arg_path: str) -> bool:
556
+ """Return whether a key path suggests URL-like content.
557
+
558
+ Returns:
559
+ `True` for URL-like key names, otherwise `False`.
560
+ """
561
+ key = arg_path.rsplit(".", maxsplit=1)[-1]
562
+ key = key.split("[", maxsplit=1)[0].lower()
563
+ return key in URL_ARG_KEYS