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.
- opscloud/__init__.py +5 -0
- opscloud/__main__.py +6 -0
- opscloud/_constants.py +53 -0
- opscloud/_debug.py +145 -0
- opscloud/_debug_buffer.py +250 -0
- opscloud/_textual_patches.py +367 -0
- opscloud/_version.py +15 -0
- opscloud/agent/__init__.py +27 -0
- opscloud/agent/config.py +168 -0
- opscloud/agent/factory.py +1103 -0
- opscloud/approval_mode.py +44 -0
- opscloud/backend/__init__.py +49 -0
- opscloud/backend/composite.py +89 -0
- opscloud/backend/local.py +183 -0
- opscloud/backend/registry.py +79 -0
- opscloud/backend/sandbox_config.py +107 -0
- opscloud/backend/sandbox_factory.py +432 -0
- opscloud/built_in_skills/remember/SKILL.md +124 -0
- opscloud/built_in_skills/skill-creator/SKILL.md +348 -0
- opscloud/built_in_skills/skill-creator/scripts/init_skill.py +371 -0
- opscloud/built_in_skills/skill-creator/scripts/quick_validate.py +165 -0
- opscloud/cli/__init__.py +1 -0
- opscloud/cli/commands/__init__.py +15 -0
- opscloud/cli/commands/auth.py +45 -0
- opscloud/cli/commands/doctor.py +100 -0
- opscloud/cli/commands/mcp.py +137 -0
- opscloud/cli/commands/threads.py +51 -0
- opscloud/cli/interactive.py +154 -0
- opscloud/cli/main.py +903 -0
- opscloud/cli/non_interactive.py +425 -0
- opscloud/cli/server_manager.py +58 -0
- opscloud/commands/__init__.py +19 -0
- opscloud/commands/_base.py +110 -0
- opscloud/commands/_guards.py +52 -0
- opscloud/commands/_router.py +146 -0
- opscloud/commands/_types.py +36 -0
- opscloud/commands/core/__init__.py +47 -0
- opscloud/commands/core/auth.py +94 -0
- opscloud/commands/core/bug.py +46 -0
- opscloud/commands/core/clear.py +106 -0
- opscloud/commands/core/cloud.py +185 -0
- opscloud/commands/core/compact.py +128 -0
- opscloud/commands/core/config_cmd.py +120 -0
- opscloud/commands/core/context.py +220 -0
- opscloud/commands/core/cost.py +44 -0
- opscloud/commands/core/doctor.py +535 -0
- opscloud/commands/core/effort.py +108 -0
- opscloud/commands/core/exit_cmd.py +41 -0
- opscloud/commands/core/fast.py +148 -0
- opscloud/commands/core/help_cmd.py +94 -0
- opscloud/commands/core/mcp.py +134 -0
- opscloud/commands/core/model.py +109 -0
- opscloud/commands/core/permissions.py +196 -0
- opscloud/commands/core/plugins.py +92 -0
- opscloud/commands/core/pool.py +108 -0
- opscloud/commands/core/resume.py +97 -0
- opscloud/commands/core/skills.py +178 -0
- opscloud/commands/power/__init__.py +51 -0
- opscloud/commands/power/agents.py +98 -0
- opscloud/commands/power/btw.py +85 -0
- opscloud/commands/power/copy.py +97 -0
- opscloud/commands/power/goal.py +548 -0
- opscloud/commands/power/loop.py +295 -0
- opscloud/commands/power/memory.py +287 -0
- opscloud/commands/power/review.py +152 -0
- opscloud/commands/power/rubric.py +287 -0
- opscloud/commands/power/runtime.py +321 -0
- opscloud/commands/power/skill_creator.py +70 -0
- opscloud/commands/power/skill_invoke.py +165 -0
- opscloud/commands/power/tasks.py +209 -0
- opscloud/commands/power/trace.py +117 -0
- opscloud/commands/power/ui_toggles.py +135 -0
- opscloud/commands/power/version.py +52 -0
- opscloud/commands/registry.py +176 -0
- opscloud/config/__init__.py +114 -0
- opscloud/config/adapters/__init__.py +5 -0
- opscloud/config/adapters/postgres.py +776 -0
- opscloud/config/adapters/sqlite.py +769 -0
- opscloud/config/aws.py +248 -0
- opscloud/config/cloud_profiles.py +437 -0
- opscloud/config/env_vars.py +81 -0
- opscloud/config/langsmith.py +319 -0
- opscloud/config/manifest.py +817 -0
- opscloud/config/metadata.py +205 -0
- opscloud/config/paths.py +604 -0
- opscloud/config/plugins.py +130 -0
- opscloud/config/settings.py +834 -0
- opscloud/config/store.py +453 -0
- opscloud/config/store_factory.py +97 -0
- opscloud/config/toml_config.py +562 -0
- opscloud/editor.py +173 -0
- opscloud/exceptions.py +65 -0
- opscloud/file_ops.py +217 -0
- opscloud/hooks/__init__.py +139 -0
- opscloud/hooks/env.py +44 -0
- opscloud/hooks/interrupt.py +121 -0
- opscloud/hooks/models/__init__.py +109 -0
- opscloud/hooks/models/adapters.py +19 -0
- opscloud/hooks/models/config.py +79 -0
- opscloud/hooks/models/domain.py +434 -0
- opscloud/hooks/models/transport.py +37 -0
- opscloud/hooks/tools.py +231 -0
- opscloud/input.py +48 -0
- opscloud/integrations/__init__.py +80 -0
- opscloud/integrations/base.py +153 -0
- opscloud/integrations/content_filter.py +119 -0
- opscloud/integrations/event_bus.py +480 -0
- opscloud/integrations/hooks.py +238 -0
- opscloud/integrations/identity.py +181 -0
- opscloud/integrations/notifications.py +134 -0
- opscloud/integrations/stream_bridge.py +397 -0
- opscloud/integrations/thread_store.py +81 -0
- opscloud/json_types.py +20 -0
- opscloud/mcp/__init__.py +57 -0
- opscloud/mcp/config.py +203 -0
- opscloud/mcp/discovery.py +229 -0
- opscloud/mcp/mcp_info.py +109 -0
- opscloud/mcp/middleware.py +102 -0
- opscloud/mcp/preload.py +505 -0
- opscloud/mcp/raw_config.py +149 -0
- opscloud/mcp/semantic_profiler.py +305 -0
- opscloud/mcp/session_manager.py +727 -0
- opscloud/mcp/trust.py +105 -0
- opscloud/memory/__init__.py +27 -0
- opscloud/memory/branch.py +61 -0
- opscloud/memory/guard.py +19 -0
- opscloud/memory/onboarding.py +120 -0
- opscloud/memory/registry.py +134 -0
- opscloud/memory/store.py +223 -0
- opscloud/middleware/__init__.py +106 -0
- opscloud/middleware/_repository_bounds.py +459 -0
- opscloud/middleware/ask_user.py +152 -0
- opscloud/middleware/auto_mode.py +60 -0
- opscloud/middleware/auto_mode_hitl.py +2434 -0
- opscloud/middleware/compaction.py +566 -0
- opscloud/middleware/configurable_model.py +233 -0
- opscloud/middleware/cost_tracking.py +1021 -0
- opscloud/middleware/glm_stall_recovery.py +109 -0
- opscloud/middleware/goal_criteria.py +1234 -0
- opscloud/middleware/goal_state_notice.py +712 -0
- opscloud/middleware/goal_tools.py +407 -0
- opscloud/middleware/headless_mcp_guard.py +88 -0
- opscloud/middleware/jev_model_router.py +1018 -0
- opscloud/middleware/local_context.py +460 -0
- opscloud/middleware/mcp_context.py +151 -0
- opscloud/middleware/mcp_middleware.py +146 -0
- opscloud/middleware/memory_guard.py +393 -0
- opscloud/middleware/model_retry.py +205 -0
- opscloud/middleware/registry.py +137 -0
- opscloud/middleware/reliable_rubric.py +709 -0
- opscloud/middleware/resume_state.py +91 -0
- opscloud/middleware/server_hooks.py +1189 -0
- opscloud/middleware/shell_allow_list.py +90 -0
- opscloud/middleware/skills.py +382 -0
- opscloud/middleware/subagent_artifacts.py +84 -0
- opscloud/middleware/subagent_telemetry.py +261 -0
- opscloud/middleware/subagents.py +243 -0
- opscloud/middleware/tool_filter.py +384 -0
- opscloud/middleware/unified_system_message.py +62 -0
- opscloud/model/__init__.py +37 -0
- opscloud/model/config.py +2992 -0
- opscloud/model/factory.py +477 -0
- opscloud/model/pool.py +459 -0
- opscloud/model/reasoning.py +282 -0
- opscloud/offload.py +173 -0
- opscloud/output.py +64 -0
- opscloud/plugins/__init__.py +189 -0
- opscloud/plugins/_json.py +22 -0
- opscloud/plugins/adapters/__init__.py +13 -0
- opscloud/plugins/adapters/agents.py +112 -0
- opscloud/plugins/adapters/commands.py +190 -0
- opscloud/plugins/adapters/mcp.py +363 -0
- opscloud/plugins/adapters/skills.py +132 -0
- opscloud/plugins/commands_cli.py +541 -0
- opscloud/plugins/discovery.py +512 -0
- opscloud/plugins/manifest.py +335 -0
- opscloud/plugins/marketplace.py +893 -0
- opscloud/plugins/models.py +334 -0
- opscloud/plugins/project_plugins.py +317 -0
- opscloud/plugins/protocol.py +31 -0
- opscloud/plugins/store.py +905 -0
- opscloud/plugins/substitution.py +122 -0
- opscloud/project_utils.py +145 -0
- opscloud/prompts/__init__.py +37 -0
- opscloud/prompts/system.py +483 -0
- opscloud/prompts/templates/system_prompt.md +166 -0
- opscloud/prompts/types.py +54 -0
- opscloud/rubrics/__init__.py +42 -0
- opscloud/rubrics/evaluator.py +404 -0
- opscloud/rubrics/evidence_extractor.py +229 -0
- opscloud/rubrics/generator.py +239 -0
- opscloud/rubrics/jev_compiler.py +175 -0
- opscloud/rubrics/jev_grader.py +297 -0
- opscloud/rubrics/middleware.py +8 -0
- opscloud/schema/__init__.py +107 -0
- opscloud/schema/interrupts.py +981 -0
- opscloud/security/__init__.py +91 -0
- opscloud/security/approval_mode.py +528 -0
- opscloud/security/approval_mode_source.py +230 -0
- opscloud/security/cli_ast_evaluator.py +552 -0
- opscloud/security/jev_classifier.py +651 -0
- opscloud/security/shell_safety.py +340 -0
- opscloud/security/unicode_security.py +439 -0
- opscloud/security/url_validation.py +160 -0
- opscloud/server/__init__.py +13 -0
- opscloud/server/_server_config.py +158 -0
- opscloud/server/server.py +242 -0
- opscloud/server/server_graph.py +93 -0
- opscloud/skills/__init__.py +91 -0
- opscloud/skills/commands.py +1099 -0
- opscloud/skills/invocation.py +200 -0
- opscloud/skills/load.py +388 -0
- opscloud/skills/loader.py +23 -0
- opscloud/skills/merge.py +65 -0
- opscloud/skills/registry.py +263 -0
- opscloud/skills/sources.py +174 -0
- opscloud/skills/trust.py +417 -0
- opscloud/state/__init__.py +142 -0
- opscloud/state/base.py +154 -0
- opscloud/state/cloud_context.py +262 -0
- opscloud/state/goal_channels.py +124 -0
- opscloud/state/goal_state_limits.py +175 -0
- opscloud/state/resume_state.py +58 -0
- opscloud/state/service_context.py +281 -0
- opscloud/state/session.py +957 -0
- opscloud/state/state_migration.py +86 -0
- opscloud/subagents/__init__.py +30 -0
- opscloud/subagents/loader.py +331 -0
- opscloud/subagents/subagents_parser.py +117 -0
- opscloud/subagents/types.py +25 -0
- opscloud/tools/__init__.py +36 -0
- opscloud/tools/catalog.py +37 -0
- opscloud/tools/display.py +24 -0
- opscloud/tools/fetch_url.py +176 -0
- opscloud/tools/goal_tools.py +423 -0
- opscloud/tools/registry.py +105 -0
- opscloud/tools/thread.py +32 -0
- opscloud/tools/web_search.py +215 -0
- opscloud/ui/__init__.py +119 -0
- opscloud/ui/app.py +5400 -0
- opscloud/ui/app.tcss +479 -0
- opscloud/ui/clipboard.py +147 -0
- opscloud/ui/command_registry.py +482 -0
- opscloud/ui/message_store.py +152 -0
- opscloud/ui/permission_store.py +360 -0
- opscloud/ui/preferences.py +113 -0
- opscloud/ui/prompt_manager.py +96 -0
- opscloud/ui/remote_client.py +505 -0
- opscloud/ui/textual_adapter.py +1269 -0
- opscloud/ui/theme.py +728 -0
- opscloud/ui/ui_help.py +415 -0
- opscloud/ui/widgets/__init__.py +383 -0
- opscloud/ui/widgets/_ask_user_types.py +81 -0
- opscloud/ui/widgets/_copy_spans.py +52 -0
- opscloud/ui/widgets/_inline_prompt.py +290 -0
- opscloud/ui/widgets/_js_eval_display.py +139 -0
- opscloud/ui/widgets/_links.py +175 -0
- opscloud/ui/widgets/_tool_stream.py +308 -0
- opscloud/ui/widgets/agent_selector.py +297 -0
- opscloud/ui/widgets/approval.py +741 -0
- opscloud/ui/widgets/ask_user.py +539 -0
- opscloud/ui/widgets/auth.py +695 -0
- opscloud/ui/widgets/auth_manager.py +214 -0
- opscloud/ui/widgets/auto_mode_notice.py +146 -0
- opscloud/ui/widgets/autocomplete.py +709 -0
- opscloud/ui/widgets/chat_input.py +805 -0
- opscloud/ui/widgets/cloud_selector.py +528 -0
- opscloud/ui/widgets/config_manager.py +652 -0
- opscloud/ui/widgets/debug_console.py +1269 -0
- opscloud/ui/widgets/devops_renderers.py +184 -0
- opscloud/ui/widgets/diff.py +237 -0
- opscloud/ui/widgets/effort_selector.py +166 -0
- opscloud/ui/widgets/goal_review.py +612 -0
- opscloud/ui/widgets/goal_status.py +57 -0
- opscloud/ui/widgets/infra_panel.py +141 -0
- opscloud/ui/widgets/install_confirm.py +114 -0
- opscloud/ui/widgets/loading.py +170 -0
- opscloud/ui/widgets/mcp_viewer.py +1204 -0
- opscloud/ui/widgets/messages.py +4461 -0
- opscloud/ui/widgets/model_selector.py +863 -0
- opscloud/ui/widgets/notification_center.py +108 -0
- opscloud/ui/widgets/notification_settings.py +119 -0
- opscloud/ui/widgets/operation_card.py +106 -0
- opscloud/ui/widgets/permissions_manager.py +667 -0
- opscloud/ui/widgets/plugin_manager.py +1212 -0
- opscloud/ui/widgets/pool_selector.py +376 -0
- opscloud/ui/widgets/preamble.py +53 -0
- opscloud/ui/widgets/skills_viewer.py +432 -0
- opscloud/ui/widgets/status.py +1037 -0
- opscloud/ui/widgets/subagent_panel.py +927 -0
- opscloud/ui/widgets/theme_selector.py +232 -0
- opscloud/ui/widgets/thread_selector.py +358 -0
- opscloud/ui/widgets/toast.py +31 -0
- opscloud/ui/widgets/tool_display.py +325 -0
- opscloud/ui/widgets/tool_renderers.py +234 -0
- opscloud/ui/widgets/tool_widgets.py +278 -0
- opscloud/ui/widgets/welcome.py +421 -0
- opscloud/ui/widgets/welcome_popup.py +102 -0
- opscloud/utils/__init__.py +24 -0
- opscloud/utils/cost_estimation.py +169 -0
- opscloud/utils/git.py +390 -0
- opscloud/utils/logger.py +571 -0
- opscloud/utils/session_stats.py +115 -0
- opscloud/utils/startup_error.py +37 -0
- talkops_opscloud-0.3.0b1.dist-info/METADATA +449 -0
- talkops_opscloud-0.3.0b1.dist-info/RECORD +309 -0
- talkops_opscloud-0.3.0b1.dist-info/WHEEL +4 -0
- talkops_opscloud-0.3.0b1.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|