talkops-opscloud 0.3.0b1__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 (309) hide show
  1. opscloud/__init__.py +5 -0
  2. opscloud/__main__.py +6 -0
  3. opscloud/_constants.py +53 -0
  4. opscloud/_debug.py +145 -0
  5. opscloud/_debug_buffer.py +250 -0
  6. opscloud/_textual_patches.py +367 -0
  7. opscloud/_version.py +15 -0
  8. opscloud/agent/__init__.py +27 -0
  9. opscloud/agent/config.py +168 -0
  10. opscloud/agent/factory.py +1103 -0
  11. opscloud/approval_mode.py +44 -0
  12. opscloud/backend/__init__.py +49 -0
  13. opscloud/backend/composite.py +89 -0
  14. opscloud/backend/local.py +183 -0
  15. opscloud/backend/registry.py +79 -0
  16. opscloud/backend/sandbox_config.py +107 -0
  17. opscloud/backend/sandbox_factory.py +432 -0
  18. opscloud/built_in_skills/remember/SKILL.md +124 -0
  19. opscloud/built_in_skills/skill-creator/SKILL.md +348 -0
  20. opscloud/built_in_skills/skill-creator/scripts/init_skill.py +371 -0
  21. opscloud/built_in_skills/skill-creator/scripts/quick_validate.py +165 -0
  22. opscloud/cli/__init__.py +1 -0
  23. opscloud/cli/commands/__init__.py +15 -0
  24. opscloud/cli/commands/auth.py +45 -0
  25. opscloud/cli/commands/doctor.py +100 -0
  26. opscloud/cli/commands/mcp.py +137 -0
  27. opscloud/cli/commands/threads.py +51 -0
  28. opscloud/cli/interactive.py +154 -0
  29. opscloud/cli/main.py +903 -0
  30. opscloud/cli/non_interactive.py +425 -0
  31. opscloud/cli/server_manager.py +58 -0
  32. opscloud/commands/__init__.py +19 -0
  33. opscloud/commands/_base.py +110 -0
  34. opscloud/commands/_guards.py +52 -0
  35. opscloud/commands/_router.py +146 -0
  36. opscloud/commands/_types.py +36 -0
  37. opscloud/commands/core/__init__.py +47 -0
  38. opscloud/commands/core/auth.py +94 -0
  39. opscloud/commands/core/bug.py +46 -0
  40. opscloud/commands/core/clear.py +106 -0
  41. opscloud/commands/core/cloud.py +185 -0
  42. opscloud/commands/core/compact.py +128 -0
  43. opscloud/commands/core/config_cmd.py +120 -0
  44. opscloud/commands/core/context.py +220 -0
  45. opscloud/commands/core/cost.py +44 -0
  46. opscloud/commands/core/doctor.py +535 -0
  47. opscloud/commands/core/effort.py +108 -0
  48. opscloud/commands/core/exit_cmd.py +41 -0
  49. opscloud/commands/core/fast.py +148 -0
  50. opscloud/commands/core/help_cmd.py +94 -0
  51. opscloud/commands/core/mcp.py +134 -0
  52. opscloud/commands/core/model.py +109 -0
  53. opscloud/commands/core/permissions.py +196 -0
  54. opscloud/commands/core/plugins.py +92 -0
  55. opscloud/commands/core/pool.py +108 -0
  56. opscloud/commands/core/resume.py +97 -0
  57. opscloud/commands/core/skills.py +178 -0
  58. opscloud/commands/power/__init__.py +51 -0
  59. opscloud/commands/power/agents.py +98 -0
  60. opscloud/commands/power/btw.py +85 -0
  61. opscloud/commands/power/copy.py +97 -0
  62. opscloud/commands/power/goal.py +548 -0
  63. opscloud/commands/power/loop.py +295 -0
  64. opscloud/commands/power/memory.py +287 -0
  65. opscloud/commands/power/review.py +152 -0
  66. opscloud/commands/power/rubric.py +287 -0
  67. opscloud/commands/power/runtime.py +321 -0
  68. opscloud/commands/power/skill_creator.py +70 -0
  69. opscloud/commands/power/skill_invoke.py +165 -0
  70. opscloud/commands/power/tasks.py +209 -0
  71. opscloud/commands/power/trace.py +117 -0
  72. opscloud/commands/power/ui_toggles.py +135 -0
  73. opscloud/commands/power/version.py +52 -0
  74. opscloud/commands/registry.py +176 -0
  75. opscloud/config/__init__.py +114 -0
  76. opscloud/config/adapters/__init__.py +5 -0
  77. opscloud/config/adapters/postgres.py +776 -0
  78. opscloud/config/adapters/sqlite.py +769 -0
  79. opscloud/config/aws.py +248 -0
  80. opscloud/config/cloud_profiles.py +437 -0
  81. opscloud/config/env_vars.py +81 -0
  82. opscloud/config/langsmith.py +319 -0
  83. opscloud/config/manifest.py +817 -0
  84. opscloud/config/metadata.py +205 -0
  85. opscloud/config/paths.py +604 -0
  86. opscloud/config/plugins.py +130 -0
  87. opscloud/config/settings.py +834 -0
  88. opscloud/config/store.py +453 -0
  89. opscloud/config/store_factory.py +97 -0
  90. opscloud/config/toml_config.py +562 -0
  91. opscloud/editor.py +173 -0
  92. opscloud/exceptions.py +65 -0
  93. opscloud/file_ops.py +217 -0
  94. opscloud/hooks/__init__.py +139 -0
  95. opscloud/hooks/env.py +44 -0
  96. opscloud/hooks/interrupt.py +121 -0
  97. opscloud/hooks/models/__init__.py +109 -0
  98. opscloud/hooks/models/adapters.py +19 -0
  99. opscloud/hooks/models/config.py +79 -0
  100. opscloud/hooks/models/domain.py +434 -0
  101. opscloud/hooks/models/transport.py +37 -0
  102. opscloud/hooks/tools.py +231 -0
  103. opscloud/input.py +48 -0
  104. opscloud/integrations/__init__.py +80 -0
  105. opscloud/integrations/base.py +153 -0
  106. opscloud/integrations/content_filter.py +119 -0
  107. opscloud/integrations/event_bus.py +480 -0
  108. opscloud/integrations/hooks.py +238 -0
  109. opscloud/integrations/identity.py +181 -0
  110. opscloud/integrations/notifications.py +134 -0
  111. opscloud/integrations/stream_bridge.py +397 -0
  112. opscloud/integrations/thread_store.py +81 -0
  113. opscloud/json_types.py +20 -0
  114. opscloud/mcp/__init__.py +57 -0
  115. opscloud/mcp/config.py +203 -0
  116. opscloud/mcp/discovery.py +229 -0
  117. opscloud/mcp/mcp_info.py +109 -0
  118. opscloud/mcp/middleware.py +102 -0
  119. opscloud/mcp/preload.py +505 -0
  120. opscloud/mcp/raw_config.py +149 -0
  121. opscloud/mcp/semantic_profiler.py +305 -0
  122. opscloud/mcp/session_manager.py +727 -0
  123. opscloud/mcp/trust.py +105 -0
  124. opscloud/memory/__init__.py +27 -0
  125. opscloud/memory/branch.py +61 -0
  126. opscloud/memory/guard.py +19 -0
  127. opscloud/memory/onboarding.py +120 -0
  128. opscloud/memory/registry.py +134 -0
  129. opscloud/memory/store.py +223 -0
  130. opscloud/middleware/__init__.py +106 -0
  131. opscloud/middleware/_repository_bounds.py +459 -0
  132. opscloud/middleware/ask_user.py +152 -0
  133. opscloud/middleware/auto_mode.py +60 -0
  134. opscloud/middleware/auto_mode_hitl.py +2434 -0
  135. opscloud/middleware/compaction.py +566 -0
  136. opscloud/middleware/configurable_model.py +233 -0
  137. opscloud/middleware/cost_tracking.py +1021 -0
  138. opscloud/middleware/glm_stall_recovery.py +109 -0
  139. opscloud/middleware/goal_criteria.py +1234 -0
  140. opscloud/middleware/goal_state_notice.py +712 -0
  141. opscloud/middleware/goal_tools.py +407 -0
  142. opscloud/middleware/headless_mcp_guard.py +88 -0
  143. opscloud/middleware/jev_model_router.py +1018 -0
  144. opscloud/middleware/local_context.py +460 -0
  145. opscloud/middleware/mcp_context.py +151 -0
  146. opscloud/middleware/mcp_middleware.py +146 -0
  147. opscloud/middleware/memory_guard.py +393 -0
  148. opscloud/middleware/model_retry.py +205 -0
  149. opscloud/middleware/registry.py +137 -0
  150. opscloud/middleware/reliable_rubric.py +709 -0
  151. opscloud/middleware/resume_state.py +91 -0
  152. opscloud/middleware/server_hooks.py +1189 -0
  153. opscloud/middleware/shell_allow_list.py +90 -0
  154. opscloud/middleware/skills.py +382 -0
  155. opscloud/middleware/subagent_artifacts.py +84 -0
  156. opscloud/middleware/subagent_telemetry.py +261 -0
  157. opscloud/middleware/subagents.py +243 -0
  158. opscloud/middleware/tool_filter.py +384 -0
  159. opscloud/middleware/unified_system_message.py +62 -0
  160. opscloud/model/__init__.py +37 -0
  161. opscloud/model/config.py +2992 -0
  162. opscloud/model/factory.py +477 -0
  163. opscloud/model/pool.py +459 -0
  164. opscloud/model/reasoning.py +282 -0
  165. opscloud/offload.py +173 -0
  166. opscloud/output.py +64 -0
  167. opscloud/plugins/__init__.py +189 -0
  168. opscloud/plugins/_json.py +22 -0
  169. opscloud/plugins/adapters/__init__.py +13 -0
  170. opscloud/plugins/adapters/agents.py +112 -0
  171. opscloud/plugins/adapters/commands.py +190 -0
  172. opscloud/plugins/adapters/mcp.py +363 -0
  173. opscloud/plugins/adapters/skills.py +132 -0
  174. opscloud/plugins/commands_cli.py +541 -0
  175. opscloud/plugins/discovery.py +512 -0
  176. opscloud/plugins/manifest.py +335 -0
  177. opscloud/plugins/marketplace.py +893 -0
  178. opscloud/plugins/models.py +334 -0
  179. opscloud/plugins/project_plugins.py +317 -0
  180. opscloud/plugins/protocol.py +31 -0
  181. opscloud/plugins/store.py +905 -0
  182. opscloud/plugins/substitution.py +122 -0
  183. opscloud/project_utils.py +145 -0
  184. opscloud/prompts/__init__.py +37 -0
  185. opscloud/prompts/system.py +483 -0
  186. opscloud/prompts/templates/system_prompt.md +166 -0
  187. opscloud/prompts/types.py +54 -0
  188. opscloud/rubrics/__init__.py +42 -0
  189. opscloud/rubrics/evaluator.py +404 -0
  190. opscloud/rubrics/evidence_extractor.py +229 -0
  191. opscloud/rubrics/generator.py +239 -0
  192. opscloud/rubrics/jev_compiler.py +175 -0
  193. opscloud/rubrics/jev_grader.py +297 -0
  194. opscloud/rubrics/middleware.py +8 -0
  195. opscloud/schema/__init__.py +107 -0
  196. opscloud/schema/interrupts.py +981 -0
  197. opscloud/security/__init__.py +91 -0
  198. opscloud/security/approval_mode.py +528 -0
  199. opscloud/security/approval_mode_source.py +230 -0
  200. opscloud/security/cli_ast_evaluator.py +552 -0
  201. opscloud/security/jev_classifier.py +651 -0
  202. opscloud/security/shell_safety.py +340 -0
  203. opscloud/security/unicode_security.py +439 -0
  204. opscloud/security/url_validation.py +160 -0
  205. opscloud/server/__init__.py +13 -0
  206. opscloud/server/_server_config.py +158 -0
  207. opscloud/server/server.py +242 -0
  208. opscloud/server/server_graph.py +93 -0
  209. opscloud/skills/__init__.py +91 -0
  210. opscloud/skills/commands.py +1099 -0
  211. opscloud/skills/invocation.py +200 -0
  212. opscloud/skills/load.py +388 -0
  213. opscloud/skills/loader.py +23 -0
  214. opscloud/skills/merge.py +65 -0
  215. opscloud/skills/registry.py +263 -0
  216. opscloud/skills/sources.py +174 -0
  217. opscloud/skills/trust.py +417 -0
  218. opscloud/state/__init__.py +142 -0
  219. opscloud/state/base.py +154 -0
  220. opscloud/state/cloud_context.py +262 -0
  221. opscloud/state/goal_channels.py +124 -0
  222. opscloud/state/goal_state_limits.py +175 -0
  223. opscloud/state/resume_state.py +58 -0
  224. opscloud/state/service_context.py +281 -0
  225. opscloud/state/session.py +957 -0
  226. opscloud/state/state_migration.py +86 -0
  227. opscloud/subagents/__init__.py +30 -0
  228. opscloud/subagents/loader.py +331 -0
  229. opscloud/subagents/subagents_parser.py +117 -0
  230. opscloud/subagents/types.py +25 -0
  231. opscloud/tools/__init__.py +36 -0
  232. opscloud/tools/catalog.py +37 -0
  233. opscloud/tools/display.py +24 -0
  234. opscloud/tools/fetch_url.py +176 -0
  235. opscloud/tools/goal_tools.py +423 -0
  236. opscloud/tools/registry.py +105 -0
  237. opscloud/tools/thread.py +32 -0
  238. opscloud/tools/web_search.py +215 -0
  239. opscloud/ui/__init__.py +119 -0
  240. opscloud/ui/app.py +5400 -0
  241. opscloud/ui/app.tcss +479 -0
  242. opscloud/ui/clipboard.py +147 -0
  243. opscloud/ui/command_registry.py +482 -0
  244. opscloud/ui/message_store.py +152 -0
  245. opscloud/ui/permission_store.py +360 -0
  246. opscloud/ui/preferences.py +113 -0
  247. opscloud/ui/prompt_manager.py +96 -0
  248. opscloud/ui/remote_client.py +505 -0
  249. opscloud/ui/textual_adapter.py +1269 -0
  250. opscloud/ui/theme.py +728 -0
  251. opscloud/ui/ui_help.py +415 -0
  252. opscloud/ui/widgets/__init__.py +383 -0
  253. opscloud/ui/widgets/_ask_user_types.py +81 -0
  254. opscloud/ui/widgets/_copy_spans.py +52 -0
  255. opscloud/ui/widgets/_inline_prompt.py +290 -0
  256. opscloud/ui/widgets/_js_eval_display.py +139 -0
  257. opscloud/ui/widgets/_links.py +175 -0
  258. opscloud/ui/widgets/_tool_stream.py +308 -0
  259. opscloud/ui/widgets/agent_selector.py +297 -0
  260. opscloud/ui/widgets/approval.py +741 -0
  261. opscloud/ui/widgets/ask_user.py +539 -0
  262. opscloud/ui/widgets/auth.py +695 -0
  263. opscloud/ui/widgets/auth_manager.py +214 -0
  264. opscloud/ui/widgets/auto_mode_notice.py +146 -0
  265. opscloud/ui/widgets/autocomplete.py +709 -0
  266. opscloud/ui/widgets/chat_input.py +805 -0
  267. opscloud/ui/widgets/cloud_selector.py +528 -0
  268. opscloud/ui/widgets/config_manager.py +652 -0
  269. opscloud/ui/widgets/debug_console.py +1269 -0
  270. opscloud/ui/widgets/devops_renderers.py +184 -0
  271. opscloud/ui/widgets/diff.py +237 -0
  272. opscloud/ui/widgets/effort_selector.py +166 -0
  273. opscloud/ui/widgets/goal_review.py +612 -0
  274. opscloud/ui/widgets/goal_status.py +57 -0
  275. opscloud/ui/widgets/infra_panel.py +141 -0
  276. opscloud/ui/widgets/install_confirm.py +114 -0
  277. opscloud/ui/widgets/loading.py +170 -0
  278. opscloud/ui/widgets/mcp_viewer.py +1204 -0
  279. opscloud/ui/widgets/messages.py +4461 -0
  280. opscloud/ui/widgets/model_selector.py +863 -0
  281. opscloud/ui/widgets/notification_center.py +108 -0
  282. opscloud/ui/widgets/notification_settings.py +119 -0
  283. opscloud/ui/widgets/operation_card.py +106 -0
  284. opscloud/ui/widgets/permissions_manager.py +667 -0
  285. opscloud/ui/widgets/plugin_manager.py +1212 -0
  286. opscloud/ui/widgets/pool_selector.py +376 -0
  287. opscloud/ui/widgets/preamble.py +53 -0
  288. opscloud/ui/widgets/skills_viewer.py +432 -0
  289. opscloud/ui/widgets/status.py +1037 -0
  290. opscloud/ui/widgets/subagent_panel.py +927 -0
  291. opscloud/ui/widgets/theme_selector.py +232 -0
  292. opscloud/ui/widgets/thread_selector.py +358 -0
  293. opscloud/ui/widgets/toast.py +31 -0
  294. opscloud/ui/widgets/tool_display.py +325 -0
  295. opscloud/ui/widgets/tool_renderers.py +234 -0
  296. opscloud/ui/widgets/tool_widgets.py +278 -0
  297. opscloud/ui/widgets/welcome.py +421 -0
  298. opscloud/ui/widgets/welcome_popup.py +102 -0
  299. opscloud/utils/__init__.py +24 -0
  300. opscloud/utils/cost_estimation.py +169 -0
  301. opscloud/utils/git.py +390 -0
  302. opscloud/utils/logger.py +571 -0
  303. opscloud/utils/session_stats.py +115 -0
  304. opscloud/utils/startup_error.py +37 -0
  305. talkops_opscloud-0.3.0b1.dist-info/METADATA +449 -0
  306. talkops_opscloud-0.3.0b1.dist-info/RECORD +309 -0
  307. talkops_opscloud-0.3.0b1.dist-info/WHEEL +4 -0
  308. talkops_opscloud-0.3.0b1.dist-info/entry_points.txt +2 -0
  309. talkops_opscloud-0.3.0b1.dist-info/licenses/LICENSE +175 -0
@@ -0,0 +1,439 @@
1
+ """Unicode security helpers for deceptive text and URL checks in OpsCloud."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ import unicodedata
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+ from urllib.parse import urlparse
10
+
11
+ _DANGEROUS_CODEPOINTS: frozenset[int] = frozenset(
12
+ {
13
+ # BiDi directional formatting controls (embeddings, overrides, pop)
14
+ *range(0x202A, 0x202F),
15
+ # BiDi isolate controls (isolates, pop isolate)
16
+ *range(0x2066, 0x206A),
17
+ # Zero-width and invisible formatting controls
18
+ 0x200B, # ZERO WIDTH SPACE
19
+ 0x200C, # ZERO WIDTH NON-JOINER
20
+ 0x200D, # ZERO WIDTH JOINER
21
+ 0x200E, # LEFT-TO-RIGHT MARK
22
+ 0x200F, # RIGHT-TO-LEFT MARK
23
+ 0x2060, # WORD JOINER
24
+ 0xFEFF, # ZERO WIDTH NO-BREAK SPACE / BOM
25
+ # Other commonly abused invisible controls
26
+ 0x00AD, # SOFT HYPHEN
27
+ 0x034F, # COMBINING GRAPHEME JOINER
28
+ 0x115F, # HANGUL CHOSEONG FILLER
29
+ 0x1160, # HANGUL JUNGSEONG FILLER
30
+ }
31
+ )
32
+
33
+ _DANGEROUS_CHARACTERS: frozenset[str] = frozenset(
34
+ chr(codepoint) for codepoint in _DANGEROUS_CODEPOINTS
35
+ )
36
+
37
+ CONFUSABLES: dict[str, str] = {
38
+ # Cyrillic
39
+ "\u0430": "a",
40
+ "\u0435": "e",
41
+ "\u043e": "o",
42
+ "\u0440": "p",
43
+ "\u0441": "c",
44
+ "\u0443": "y",
45
+ "\u0445": "x",
46
+ "\u043d": "h",
47
+ "\u0456": "i",
48
+ "\u0458": "j",
49
+ "\u043a": "k",
50
+ "\u0455": "s",
51
+ # Greek
52
+ "\u03b1": "a",
53
+ "\u03b5": "e",
54
+ "\u03bf": "o",
55
+ "\u03c1": "p",
56
+ "\u03c7": "x",
57
+ "\u03ba": "k",
58
+ "\u03bd": "v",
59
+ "\u03c4": "t",
60
+ # Armenian
61
+ "\u0570": "h",
62
+ "\u0578": "n",
63
+ "\u057d": "u",
64
+ # Fullwidth Latin
65
+ "\uff41": "a",
66
+ "\uff45": "e",
67
+ "\uff4f": "o",
68
+ }
69
+
70
+ URL_ARG_KEYS: frozenset[str] = frozenset(
71
+ {"url", "uri", "href", "link", "base_url", "endpoint"}
72
+ )
73
+
74
+ _URL_SAFE_LOCAL_HOSTS: frozenset[str] = frozenset({"localhost"})
75
+
76
+
77
+ @dataclass(frozen=True, slots=True)
78
+ class UnicodeIssue:
79
+ position: int
80
+ character: str
81
+ codepoint: str
82
+ name: str
83
+
84
+ def __post_init__(self) -> None:
85
+ if len(self.character) != 1:
86
+ msg = f"character must be a single code point, got length {len(self.character)}"
87
+ raise ValueError(msg)
88
+ expected = f"U+{ord(self.character):04X}"
89
+ if self.codepoint != expected:
90
+ msg = f"codepoint {self.codepoint!r} does not match character (expected {expected})"
91
+ raise ValueError(msg)
92
+
93
+
94
+ @dataclass(frozen=True, slots=True)
95
+ class UrlSafetyResult:
96
+ safe: bool
97
+ decoded_domain: str | None
98
+ warnings: tuple[str, ...]
99
+ issues: tuple[UnicodeIssue, ...]
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class UnicodeSanitizeResult:
104
+ """Result of sanitizing input text."""
105
+
106
+ sanitized_text: str
107
+ removed_characters_count: int
108
+ has_bidi_controls: bool
109
+ has_invisible_chars: bool
110
+
111
+
112
+ def detect_dangerous_unicode(text: str) -> list[UnicodeIssue]:
113
+ issues: list[UnicodeIssue] = []
114
+ for position, character in enumerate(text):
115
+ if character not in _DANGEROUS_CHARACTERS:
116
+ continue
117
+ issues.append(
118
+ UnicodeIssue(
119
+ position=position,
120
+ character=character,
121
+ codepoint=_format_codepoint(character),
122
+ name=_unicode_name(character),
123
+ )
124
+ )
125
+ return issues
126
+
127
+
128
+ def strip_dangerous_unicode(text: str) -> str:
129
+ return "".join(ch for ch in text if ch not in _DANGEROUS_CHARACTERS)
130
+
131
+
132
+ def sanitize_control_chars(
133
+ text: str,
134
+ *,
135
+ keep_newlines: bool = False,
136
+ collapse_whitespace: bool = True,
137
+ max_length: int | None = None,
138
+ ) -> str:
139
+ allowed = {" ", "\n"} if keep_newlines else {" "}
140
+ cleaned = "".join(
141
+ ch if ch in allowed or not unicodedata.category(ch).startswith("C") else " "
142
+ for ch in strip_dangerous_unicode(text)
143
+ )
144
+ if collapse_whitespace:
145
+ if keep_newlines:
146
+ cleaned = "\n".join(" ".join(line.split()) for line in cleaned.split("\n"))
147
+ else:
148
+ cleaned = " ".join(cleaned.split())
149
+ if max_length is not None and len(cleaned) > max_length:
150
+ cleaned = cleaned[: max_length - 1].rstrip() + "…"
151
+ return cleaned
152
+
153
+
154
+ def render_with_unicode_markers(text: str) -> str:
155
+ rendered_parts: list[str] = []
156
+ for character in text:
157
+ if character not in _DANGEROUS_CHARACTERS:
158
+ rendered_parts.append(character)
159
+ continue
160
+ rendered_parts.append(
161
+ f"<{_format_codepoint(character)} {_unicode_name(character)}>"
162
+ )
163
+ return "".join(rendered_parts)
164
+
165
+
166
+ def summarize_issues(issues: list[UnicodeIssue], *, max_items: int = 3) -> str:
167
+ unique_entries: list[str] = []
168
+ seen: set[str] = set()
169
+ for issue in issues:
170
+ entry = f"{issue.codepoint} {issue.name}"
171
+ if entry in seen:
172
+ continue
173
+ seen.add(entry)
174
+ unique_entries.append(entry)
175
+
176
+ if len(unique_entries) <= max_items:
177
+ return ", ".join(unique_entries)
178
+
179
+ displayed = ", ".join(unique_entries[:max_items])
180
+ remainder = len(unique_entries) - max_items
181
+ suffix = "entry" if remainder == 1 else "entries"
182
+ return f"{displayed}, +{remainder} more {suffix}"
183
+
184
+
185
+ def format_warning_detail(warnings: tuple[str, ...], *, max_shown: int = 2) -> str:
186
+ shown = warnings[:max_shown]
187
+ detail = "; ".join(shown)
188
+ remaining = len(warnings) - max_shown
189
+ if remaining > 0:
190
+ detail += f"; +{remaining} more"
191
+ return detail
192
+
193
+
194
+ def sanitize_unicode(text: str) -> UnicodeSanitizeResult:
195
+ """Sanitize potentially deceptive unicode characters from command or prompt."""
196
+ if not text:
197
+ return UnicodeSanitizeResult(text, 0, False, False)
198
+
199
+ removed = 0
200
+ has_bidi = False
201
+ has_invisible = False
202
+ cleaned_chars: list[str] = []
203
+
204
+ for char in text:
205
+ cp = ord(char)
206
+ if cp in _DANGEROUS_CODEPOINTS:
207
+ removed += 1
208
+ if (0x202A <= cp <= 0x202E) or (0x2066 <= cp <= 0x2069):
209
+ has_bidi = True
210
+ else:
211
+ has_invisible = True
212
+ else:
213
+ cleaned_chars.append(char)
214
+
215
+ sanitized = unicodedata.normalize("NFC", "".join(cleaned_chars))
216
+ return UnicodeSanitizeResult(sanitized, removed, has_bidi, has_invisible)
217
+
218
+
219
+ def normalize_confusables(text: str) -> str:
220
+ """Replace visually confusable homoglyphs with their ASCII equivalents."""
221
+ return "".join(CONFUSABLES.get(c, c) for c in text)
222
+
223
+
224
+ def check_url_safety(url: str) -> UrlSafetyResult:
225
+ warnings: list[str] = []
226
+ suspicious = False
227
+
228
+ issues = detect_dangerous_unicode(url)
229
+ if issues:
230
+ suspicious = True
231
+ warnings.append(
232
+ f"URL contains hidden Unicode characters ({summarize_issues(issues)})"
233
+ )
234
+
235
+ parsed = urlparse(url)
236
+ hostname = parsed.hostname
237
+ if not hostname:
238
+ return UrlSafetyResult(
239
+ safe=not suspicious,
240
+ decoded_domain=None,
241
+ warnings=tuple(warnings),
242
+ issues=tuple(issues),
243
+ )
244
+
245
+ decoded_hostname, failed_punycode = _decode_hostname(hostname)
246
+ decoded_domain = decoded_hostname if decoded_hostname != hostname else None
247
+ if decoded_domain:
248
+ warnings.append(f"Punycode domain decodes to '{decoded_domain}'")
249
+ if failed_punycode:
250
+ suspicious = True
251
+ labels = ", ".join(failed_punycode)
252
+ warnings.append(f"Punycode label(s) could not be decoded: {labels}")
253
+
254
+ if _is_local_or_ip_hostname(decoded_hostname):
255
+ return UrlSafetyResult(
256
+ safe=not suspicious,
257
+ decoded_domain=decoded_domain,
258
+ warnings=tuple(warnings),
259
+ issues=tuple(issues),
260
+ )
261
+
262
+ for label in _split_hostname_labels(decoded_hostname):
263
+ scripts = _scripts_in_label(label)
264
+ if len(scripts) > 1:
265
+ suspicious = True
266
+ script_names = ", ".join(sorted(scripts))
267
+ warnings.append(f"Domain label '{label}' mixes scripts ({script_names})")
268
+
269
+ if _label_has_suspicious_confusable_mix(label):
270
+ suspicious = True
271
+ warnings.append(
272
+ f"Domain label '{label}' contains confusable Unicode characters"
273
+ )
274
+
275
+ return UrlSafetyResult(
276
+ safe=not suspicious,
277
+ decoded_domain=decoded_domain,
278
+ warnings=tuple(warnings),
279
+ issues=tuple(issues),
280
+ )
281
+
282
+
283
+ def _decode_hostname(hostname: str) -> tuple[str, list[str]]:
284
+ decoded_labels: list[str] = []
285
+ failed_labels: list[str] = []
286
+ for label in _split_hostname_labels(hostname):
287
+ if label.startswith("xn--"):
288
+ try:
289
+ decoded_labels.append(label.encode("ascii").decode("idna"))
290
+ except UnicodeError:
291
+ decoded_labels.append(label)
292
+ failed_labels.append(label)
293
+ continue
294
+ decoded_labels.append(label)
295
+ return ".".join(decoded_labels), failed_labels
296
+
297
+
298
+ def _split_hostname_labels(hostname: str) -> list[str]:
299
+ return [label for label in hostname.split(".") if label]
300
+
301
+
302
+ def _is_local_or_ip_hostname(hostname: str) -> bool:
303
+ host = hostname.strip().rstrip(".")
304
+ if not host:
305
+ return False
306
+
307
+ if host.lower() in _URL_SAFE_LOCAL_HOSTS:
308
+ return True
309
+
310
+ try:
311
+ ipaddress.ip_address(host)
312
+ except ValueError:
313
+ return False
314
+ return True
315
+
316
+
317
+ def _scripts_in_label(label: str) -> set[str]:
318
+ scripts: set[str] = set()
319
+ for character in label:
320
+ script = _char_script(character)
321
+ if script in {"Common", "Inherited"}:
322
+ continue
323
+ scripts.add(script)
324
+ return scripts
325
+
326
+
327
+ def _label_has_suspicious_confusable_mix(label: str) -> bool:
328
+ if not any(character in CONFUSABLES for character in label):
329
+ return False
330
+
331
+ scripts = _scripts_in_label(label)
332
+ return len(scripts) > 1
333
+
334
+
335
+ def _char_script(character: str) -> str:
336
+ name = unicodedata.name(character, "")
337
+ category = unicodedata.category(character)
338
+
339
+ if "FULLWIDTH LATIN" in name:
340
+ return "Fullwidth"
341
+ if "LATIN" in name:
342
+ return "Latin"
343
+ if "CYRILLIC" in name:
344
+ return "Cyrillic"
345
+ if "GREEK" in name:
346
+ return "Greek"
347
+ if "ARMENIAN" in name:
348
+ return "Armenian"
349
+ if any(
350
+ token in name
351
+ for token in (
352
+ "CJK",
353
+ "HIRAGANA",
354
+ "KATAKANA",
355
+ "HANGUL",
356
+ "BOPOMOFO",
357
+ "IDEOGRAPHIC",
358
+ )
359
+ ):
360
+ return "EastAsian"
361
+
362
+ if category.startswith("M"):
363
+ return "Inherited"
364
+ if category[0] in {"N", "P", "S", "Z", "C"}:
365
+ return "Common"
366
+
367
+ return "Other"
368
+
369
+
370
+ def _format_codepoint(character: str) -> str:
371
+ return f"U+{ord(character):04X}"
372
+
373
+
374
+ def _unicode_name(character: str) -> str:
375
+ return unicodedata.name(character, "UNKNOWN CHARACTER")
376
+
377
+
378
+ def iter_string_values(
379
+ data: dict[str, Any],
380
+ *,
381
+ prefix: str = "",
382
+ ) -> list[tuple[str, str]]:
383
+ values: list[tuple[str, str]] = []
384
+ for key, value in data.items():
385
+ key_path = f"{prefix}.{key}" if prefix else key
386
+ if isinstance(value, str):
387
+ values.append((key_path, value))
388
+ continue
389
+ if isinstance(value, dict):
390
+ values.extend(iter_string_values(value, prefix=key_path))
391
+ continue
392
+ if isinstance(value, list):
393
+ values.extend(_iter_string_values_from_list(value, prefix=key_path))
394
+ return values
395
+
396
+
397
+ def _iter_string_values_from_list(
398
+ values: list[Any],
399
+ *,
400
+ prefix: str,
401
+ ) -> list[tuple[str, str]]:
402
+ entries: list[tuple[str, str]] = []
403
+ for index, value in enumerate(values):
404
+ key_path = f"{prefix}[{index}]"
405
+ if isinstance(value, str):
406
+ entries.append((key_path, value))
407
+ continue
408
+ if isinstance(value, dict):
409
+ entries.extend(iter_string_values(value, prefix=key_path))
410
+ continue
411
+ if isinstance(value, list):
412
+ entries.extend(_iter_string_values_from_list(value, prefix=key_path))
413
+ return entries
414
+
415
+
416
+ def looks_like_url_key(arg_path: str) -> bool:
417
+ key = arg_path.rsplit(".", maxsplit=1)[-1]
418
+ key = key.split("[", maxsplit=1)[0].lower()
419
+ return key in URL_ARG_KEYS
420
+
421
+
422
+ __all__ = [
423
+ "CONFUSABLES",
424
+ "URL_ARG_KEYS",
425
+ "UnicodeIssue",
426
+ "UnicodeSanitizeResult",
427
+ "UrlSafetyResult",
428
+ "check_url_safety",
429
+ "detect_dangerous_unicode",
430
+ "format_warning_detail",
431
+ "iter_string_values",
432
+ "looks_like_url_key",
433
+ "normalize_confusables",
434
+ "render_with_unicode_markers",
435
+ "sanitize_control_chars",
436
+ "sanitize_unicode",
437
+ "strip_dangerous_unicode",
438
+ "summarize_issues",
439
+ ]
@@ -0,0 +1,160 @@
1
+ """SSRF validation and DNS pinning for HTTP operations in OpsCloud."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ import contextlib
7
+ import ipaddress
8
+ import socket
9
+ import threading
10
+ from typing import Any
11
+ from urllib.parse import urlparse
12
+
13
+ from opscloud.utils.logger import get_logger
14
+
15
+ logger = get_logger(__name__)
16
+
17
+ _ALLOWED_URL_SCHEMES = frozenset({"http", "https"})
18
+ _dns_pin_lock = threading.Lock()
19
+
20
+
21
+ class _UrlValidationError(ValueError):
22
+ """Raised for scheme, DNS, or SSRF-blocked URL validation errors."""
23
+
24
+
25
+ UrlValidationError = _UrlValidationError
26
+
27
+
28
+ def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
29
+ """Return True if ip is in private, loopback, link-local, or non-global ranges."""
30
+ if isinstance(ip, ipaddress.IPv6Address):
31
+ if ip.ipv4_mapped is not None:
32
+ ip = ip.ipv4_mapped
33
+ elif ip.sixtofour is not None:
34
+ ip = ip.sixtofour
35
+ return (
36
+ not ip.is_global
37
+ or ip.is_private
38
+ or ip.is_loopback
39
+ or ip.is_link_local
40
+ or ip.is_reserved
41
+ or ip.is_multicast
42
+ or ip.is_unspecified
43
+ )
44
+
45
+
46
+ def _validate_url(url: str) -> list[str]:
47
+ """Resolve and validate that the URL targets only safe, public IP addresses."""
48
+ parsed = urlparse(url)
49
+ if parsed.scheme not in _ALLOWED_URL_SCHEMES:
50
+ msg = f"URL scheme not allowed: {parsed.scheme!r} (must be http or https)"
51
+ raise _UrlValidationError(msg)
52
+
53
+ hostname = parsed.hostname
54
+ if not hostname:
55
+ msg = "URL is missing a hostname"
56
+ raise _UrlValidationError(msg)
57
+
58
+ try:
59
+ encoded_hostname = hostname.encode("idna").decode("ascii")
60
+ except UnicodeError as exc:
61
+ msg = f"Could not encode hostname {hostname!r} as IDNA: {exc}"
62
+ raise _UrlValidationError(msg) from exc
63
+
64
+ try:
65
+ infos = socket.getaddrinfo(
66
+ encoded_hostname,
67
+ None,
68
+ type=socket.SOCK_STREAM,
69
+ proto=socket.IPPROTO_TCP,
70
+ )
71
+ except socket.gaierror as exc:
72
+ msg = f"Could not resolve hostname {hostname!r}: {exc}"
73
+ raise _UrlValidationError(msg) from exc
74
+
75
+ validated_ips: list[str] = []
76
+ for info in infos:
77
+ raw_ip = str(info[4][0]).split("%", 1)[0]
78
+ ip = ipaddress.ip_address(raw_ip)
79
+ if _is_blocked_ip(ip):
80
+ logger.warning(
81
+ "SSRF guard blocked URL %r: hostname %r resolves to %s",
82
+ url,
83
+ hostname,
84
+ ip,
85
+ )
86
+ msg = (
87
+ f"URL hostname {hostname!r} resolves to blocked address {ip} "
88
+ "(private, loopback, link-local, reserved, or non-global range)"
89
+ )
90
+ raise _UrlValidationError(msg)
91
+ validated_ips.append(raw_ip)
92
+
93
+ if not validated_ips:
94
+ msg = f"Hostname {hostname!r} resolved to no addresses"
95
+ raise _UrlValidationError(msg)
96
+
97
+ return validated_ips
98
+
99
+
100
+ @contextlib.contextmanager
101
+ def _pinned_dns(hostname: str, allowed_ips: list[str]) -> Iterator[None]:
102
+ """Force outgoing urllib3 connections to use pre-validated IPs to prevent TOCTOU DNS rebinding."""
103
+ from urllib3.util import connection as urllib3_connection
104
+
105
+ with _dns_pin_lock:
106
+ original = urllib3_connection.create_connection
107
+
108
+ def patched(address: tuple[str, int], *args: Any, **kwargs: Any) -> socket.socket:
109
+ """Intercept connection creation to bind pre-validated IP addresses.
110
+
111
+ Args:
112
+ address: Destination (host, port) pair.
113
+ *args: Additional positional arguments for create_connection.
114
+ **kwargs: Additional keyword arguments for create_connection.
115
+
116
+ Returns:
117
+ socket.socket: Connected socket using pre-validated IP.
118
+
119
+ Raises:
120
+ OSError: If connection fails for all validated IPs.
121
+ """
122
+ host, port = address[0], address[1]
123
+ if host != hostname:
124
+ return original(address, *args, **kwargs)
125
+ last_exc: OSError | None = None
126
+ for ip in allowed_ips:
127
+ try:
128
+ return original((ip, port), *args, **kwargs)
129
+ except OSError as exc:
130
+ last_exc = exc
131
+ assert last_exc is not None
132
+ raise last_exc
133
+
134
+ urllib3_connection.create_connection = patched
135
+ try:
136
+ yield
137
+ finally:
138
+ urllib3_connection.create_connection = original
139
+
140
+
141
+ def is_url_safe(url: str) -> bool:
142
+ """Return True if URL passes validation checks."""
143
+ try:
144
+ _validate_url(url)
145
+ return True
146
+ except Exception:
147
+ return False
148
+
149
+
150
+ validate_url = _validate_url
151
+
152
+ __all__ = [
153
+ "UrlValidationError",
154
+ "_UrlValidationError",
155
+ "_is_blocked_ip",
156
+ "_pinned_dns",
157
+ "_validate_url",
158
+ "is_url_safe",
159
+ "validate_url",
160
+ ]
@@ -0,0 +1,13 @@
1
+ """LangGraph dev server integration package."""
2
+
3
+ from opscloud._constants import SERVER_ENV_PREFIX
4
+ from opscloud.server._server_config import ServerConfig
5
+ from opscloud.server.server import ServerProcess, find_free_port, generate_langgraph_json
6
+
7
+ __all__ = [
8
+ "SERVER_ENV_PREFIX",
9
+ "ServerConfig",
10
+ "ServerProcess",
11
+ "find_free_port",
12
+ "generate_langgraph_json",
13
+ ]