velune-cli 1.0.0__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 (229) hide show
  1. velune/__init__.py +5 -0
  2. velune/__main__.py +6 -0
  3. velune/cli/__init__.py +5 -0
  4. velune/cli/app.py +212 -0
  5. velune/cli/autocomplete.py +76 -0
  6. velune/cli/banner.py +98 -0
  7. velune/cli/commands/__init__.py +32 -0
  8. velune/cli/commands/ask.py +149 -0
  9. velune/cli/commands/base.py +16 -0
  10. velune/cli/commands/chat.py +188 -0
  11. velune/cli/commands/config.py +182 -0
  12. velune/cli/commands/daemon.py +85 -0
  13. velune/cli/commands/doctor.py +373 -0
  14. velune/cli/commands/init.py +160 -0
  15. velune/cli/commands/mcp.py +80 -0
  16. velune/cli/commands/memory.py +269 -0
  17. velune/cli/commands/models.py +462 -0
  18. velune/cli/commands/preflight.py +95 -0
  19. velune/cli/commands/run.py +171 -0
  20. velune/cli/commands/setup.py +182 -0
  21. velune/cli/commands/workspace.py +217 -0
  22. velune/cli/context.py +37 -0
  23. velune/cli/councilmodel_ui.py +171 -0
  24. velune/cli/display/council_view.py +240 -0
  25. velune/cli/display/memory_view.py +93 -0
  26. velune/cli/display/panels.py +35 -0
  27. velune/cli/display/progress.py +25 -0
  28. velune/cli/display/themes.py +21 -0
  29. velune/cli/main.py +15 -0
  30. velune/cli/model_selector.py +44 -0
  31. velune/cli/modes.py +86 -0
  32. velune/cli/pull_ui.py +118 -0
  33. velune/cli/registry.py +81 -0
  34. velune/cli/repl.py +1178 -0
  35. velune/cli/session_manager.py +69 -0
  36. velune/cli/slash_commands.py +37 -0
  37. velune/cognition/__init__.py +19 -0
  38. velune/cognition/arbitrator.py +216 -0
  39. velune/cognition/architecture.py +398 -0
  40. velune/cognition/council/__init__.py +47 -0
  41. velune/cognition/council/base.py +216 -0
  42. velune/cognition/council/challenger.py +70 -0
  43. velune/cognition/council/coder.py +79 -0
  44. velune/cognition/council/critic_agent.py +39 -0
  45. velune/cognition/council/critic_configs.py +111 -0
  46. velune/cognition/council/critics.py +41 -0
  47. velune/cognition/council/debate.py +44 -0
  48. velune/cognition/council/factory.py +140 -0
  49. velune/cognition/council/messages.py +53 -0
  50. velune/cognition/council/planner.py +119 -0
  51. velune/cognition/council/reviewer.py +72 -0
  52. velune/cognition/council/synthesizer.py +67 -0
  53. velune/cognition/council/tiers.py +181 -0
  54. velune/cognition/firewall.py +256 -0
  55. velune/cognition/module.py +38 -0
  56. velune/cognition/orchestrator.py +886 -0
  57. velune/cognition/personality.py +236 -0
  58. velune/cognition/style_resolver.py +62 -0
  59. velune/cognition/verification.py +201 -0
  60. velune/context/__init__.py +11 -0
  61. velune/context/extractive.py +94 -0
  62. velune/context/window.py +62 -0
  63. velune/core/__init__.py +89 -0
  64. velune/core/background.py +5 -0
  65. velune/core/config/__init__.py +37 -0
  66. velune/core/errors/__init__.py +53 -0
  67. velune/core/errors/execution.py +26 -0
  68. velune/core/errors/memory.py +21 -0
  69. velune/core/errors/orchestration.py +26 -0
  70. velune/core/errors/provider.py +31 -0
  71. velune/core/event_loop.py +30 -0
  72. velune/core/logging.py +83 -0
  73. velune/core/runtime.py +106 -0
  74. velune/core/task_registry.py +120 -0
  75. velune/core/trace.py +80 -0
  76. velune/core/types/__init__.py +48 -0
  77. velune/core/types/agent.py +49 -0
  78. velune/core/types/context.py +39 -0
  79. velune/core/types/inference.py +35 -0
  80. velune/core/types/memory.py +39 -0
  81. velune/core/types/model.py +64 -0
  82. velune/core/types/provider.py +35 -0
  83. velune/core/types/repository.py +35 -0
  84. velune/core/types/task.py +56 -0
  85. velune/core/types/workspace.py +28 -0
  86. velune/daemon/client.py +13 -0
  87. velune/daemon/server.py +115 -0
  88. velune/daemon/transport.py +169 -0
  89. velune/events.py +194 -0
  90. velune/execution/__init__.py +22 -0
  91. velune/execution/benchmarker.py +311 -0
  92. velune/execution/cancellation.py +53 -0
  93. velune/execution/checkpointer.py +128 -0
  94. velune/execution/command_spec.py +140 -0
  95. velune/execution/diff_preview.py +172 -0
  96. velune/execution/executor.py +173 -0
  97. velune/execution/module.py +16 -0
  98. velune/execution/multi_diff.py +70 -0
  99. velune/execution/path_guard.py +19 -0
  100. velune/execution/planner.py +91 -0
  101. velune/execution/rollback.py +80 -0
  102. velune/execution/sandbox.py +257 -0
  103. velune/execution/validator.py +113 -0
  104. velune/hardware/__init__.py +1 -0
  105. velune/hardware/detector.py +162 -0
  106. velune/kernel/__init__.py +58 -0
  107. velune/kernel/bootstrap.py +107 -0
  108. velune/kernel/config.py +252 -0
  109. velune/kernel/health.py +54 -0
  110. velune/kernel/lifecycle.py +102 -0
  111. velune/kernel/module.py +15 -0
  112. velune/kernel/modules.py +23 -0
  113. velune/kernel/registry.py +93 -0
  114. velune/kernel/schemas.py +28 -0
  115. velune/main.py +9 -0
  116. velune/mcp/__init__.py +9 -0
  117. velune/mcp/client.py +113 -0
  118. velune/mcp/config.py +19 -0
  119. velune/mcp/server.py +90 -0
  120. velune/memory/__init__.py +28 -0
  121. velune/memory/lifecycle.py +154 -0
  122. velune/memory/module.py +94 -0
  123. velune/memory/prioritizer.py +65 -0
  124. velune/memory/storage/sqlite_manager.py +368 -0
  125. velune/memory/tiers/episodic.py +156 -0
  126. velune/memory/tiers/graph.py +282 -0
  127. velune/memory/tiers/lineage.py +367 -0
  128. velune/memory/tiers/semantic.py +198 -0
  129. velune/memory/tiers/working.py +165 -0
  130. velune/models/__init__.py +16 -0
  131. velune/models/module.py +18 -0
  132. velune/models/probes.py +182 -0
  133. velune/models/profile_cache.py +82 -0
  134. velune/models/profiler.py +105 -0
  135. velune/models/registry.py +201 -0
  136. velune/models/scorer.py +225 -0
  137. velune/models/specializations.py +196 -0
  138. velune/orchestration/__init__.py +15 -0
  139. velune/orchestration/module.py +14 -0
  140. velune/orchestration/role_assignments.py +78 -0
  141. velune/orchestration/schemas.py +99 -0
  142. velune/plugins/__init__.py +13 -0
  143. velune/plugins/hooks.py +49 -0
  144. velune/plugins/loader.py +95 -0
  145. velune/plugins/registry.py +54 -0
  146. velune/plugins/schemas.py +19 -0
  147. velune/providers/__init__.py +18 -0
  148. velune/providers/adapters/anthropic.py +231 -0
  149. velune/providers/adapters/fireworks.py +115 -0
  150. velune/providers/adapters/google.py +234 -0
  151. velune/providers/adapters/groq.py +151 -0
  152. velune/providers/adapters/huggingface.py +203 -0
  153. velune/providers/adapters/llamacpp.py +202 -0
  154. velune/providers/adapters/lmstudio.py +173 -0
  155. velune/providers/adapters/ollama.py +186 -0
  156. velune/providers/adapters/openai.py +207 -0
  157. velune/providers/adapters/openrouter.py +81 -0
  158. velune/providers/adapters/together.py +134 -0
  159. velune/providers/adapters/xai.py +60 -0
  160. velune/providers/base.py +86 -0
  161. velune/providers/benchmarker.py +135 -0
  162. velune/providers/discovery/__init__.py +33 -0
  163. velune/providers/discovery/anthropic.py +77 -0
  164. velune/providers/discovery/benchmarks.py +44 -0
  165. velune/providers/discovery/classifier.py +69 -0
  166. velune/providers/discovery/fireworks.py +95 -0
  167. velune/providers/discovery/gguf.py +87 -0
  168. velune/providers/discovery/google.py +95 -0
  169. velune/providers/discovery/gpu.py +109 -0
  170. velune/providers/discovery/groq.py +20 -0
  171. velune/providers/discovery/huggingface.py +67 -0
  172. velune/providers/discovery/lmstudio.py +80 -0
  173. velune/providers/discovery/ollama.py +165 -0
  174. velune/providers/discovery/openai.py +96 -0
  175. velune/providers/discovery/openrouter.py +113 -0
  176. velune/providers/discovery/scanner.py +114 -0
  177. velune/providers/discovery/together.py +114 -0
  178. velune/providers/discovery/xai.py +57 -0
  179. velune/providers/keystore.py +83 -0
  180. velune/providers/local_paths.py +49 -0
  181. velune/providers/local_resolver.py +208 -0
  182. velune/providers/module.py +15 -0
  183. velune/providers/ollama_manager.py +193 -0
  184. velune/providers/registry.py +173 -0
  185. velune/providers/router.py +82 -0
  186. velune/py.typed +0 -0
  187. velune/repository/__init__.py +21 -0
  188. velune/repository/analyzer.py +123 -0
  189. velune/repository/cognition.py +172 -0
  190. velune/repository/grapher.py +182 -0
  191. velune/repository/indexer.py +229 -0
  192. velune/repository/module.py +15 -0
  193. velune/repository/parser.py +378 -0
  194. velune/repository/project_type.py +293 -0
  195. velune/repository/scanner.py +177 -0
  196. velune/repository/schemas.py +102 -0
  197. velune/repository/tracker.py +233 -0
  198. velune/retrieval/__init__.py +27 -0
  199. velune/retrieval/graph.py +117 -0
  200. velune/retrieval/hybrid.py +250 -0
  201. velune/retrieval/keyword.py +113 -0
  202. velune/retrieval/module.py +19 -0
  203. velune/retrieval/reranker.py +68 -0
  204. velune/retrieval/schemas.py +59 -0
  205. velune/retrieval/vector.py +163 -0
  206. velune/telemetry/__init__.py +7 -0
  207. velune/telemetry/cognition.py +260 -0
  208. velune/telemetry/token_tracker.py +133 -0
  209. velune/tools/__init__.py +41 -0
  210. velune/tools/base/registry.py +84 -0
  211. velune/tools/base/tool.py +63 -0
  212. velune/tools/code/navigate.py +107 -0
  213. velune/tools/code/search.py +112 -0
  214. velune/tools/filesystem/read.py +75 -0
  215. velune/tools/filesystem/search.py +123 -0
  216. velune/tools/filesystem/write.py +160 -0
  217. velune/tools/git/history.py +185 -0
  218. velune/tools/git/operations.py +132 -0
  219. velune/tools/git/state.py +134 -0
  220. velune/tools/module.py +65 -0
  221. velune/tools/terminal/execute.py +72 -0
  222. velune/tools/terminal/history.py +47 -0
  223. velune/tools/web/fetch.py +55 -0
  224. velune/tools/web/validator.py +96 -0
  225. velune_cli-1.0.0.dist-info/METADATA +497 -0
  226. velune_cli-1.0.0.dist-info/RECORD +229 -0
  227. velune_cli-1.0.0.dist-info/WHEEL +4 -0
  228. velune_cli-1.0.0.dist-info/entry_points.txt +2 -0
  229. velune_cli-1.0.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from velune.tools.base.tool import BaseTool
6
+
7
+
8
+ class GitStatus(BaseTool):
9
+ """Tool for viewing git status."""
10
+
11
+ def get_name(self) -> str:
12
+ return "git_status"
13
+
14
+ def get_description(self) -> str:
15
+ return "View git repository status"
16
+
17
+ async def execute(self, directory: str = ".") -> dict:
18
+ """Get git status."""
19
+ import subprocess
20
+
21
+ root_path = Path(directory)
22
+ if not (root_path / ".git").exists():
23
+ raise ValueError("Not a git repository")
24
+
25
+ import asyncio
26
+ import functools
27
+ result = await asyncio.to_thread(
28
+ functools.partial(
29
+ subprocess.run,
30
+ ["git", "status", "--porcelain"],
31
+ cwd=root_path,
32
+ capture_output=True,
33
+ text=True,
34
+ )
35
+ )
36
+
37
+ status = {
38
+ "modified": [],
39
+ "added": [],
40
+ "deleted": [],
41
+ "untracked": [],
42
+ }
43
+
44
+ for line in result.stdout.strip().split("\n"):
45
+ if line:
46
+ status_code = line[:2]
47
+ file_path = line[3:]
48
+
49
+ if status_code[0] == "M":
50
+ status["modified"].append(file_path)
51
+ elif status_code[0] == "A":
52
+ status["added"].append(file_path)
53
+ elif status_code[0] == "D":
54
+ status["deleted"].append(file_path)
55
+ elif status_code[0] == "?":
56
+ status["untracked"].append(file_path)
57
+
58
+ return status
59
+
60
+ def get_schema(self) -> dict:
61
+ return {
62
+ "type": "object",
63
+ "properties": {
64
+ "directory": {
65
+ "type": "string",
66
+ "description": "Git repository directory",
67
+ },
68
+ },
69
+ }
70
+
71
+
72
+ class GitBranch(BaseTool):
73
+ """Tool for viewing git branches."""
74
+
75
+ def get_name(self) -> str:
76
+ return "git_branch"
77
+
78
+ def get_description(self) -> str:
79
+ return "View git branches"
80
+
81
+ async def execute(self, directory: str = ".") -> dict:
82
+ """Get git branches."""
83
+ import subprocess
84
+
85
+ root_path = Path(directory)
86
+ if not (root_path / ".git").exists():
87
+ raise ValueError("Not a git repository")
88
+
89
+ # Get current branch
90
+ import asyncio
91
+ import functools
92
+ current_result = await asyncio.to_thread(
93
+ functools.partial(
94
+ subprocess.run,
95
+ ["git", "branch", "--show-current"],
96
+ cwd=root_path,
97
+ capture_output=True,
98
+ text=True,
99
+ )
100
+ )
101
+ current_branch = current_result.stdout.strip()
102
+
103
+ # Get all branches
104
+ all_result = await asyncio.to_thread(
105
+ functools.partial(
106
+ subprocess.run,
107
+ ["git", "branch", "-a"],
108
+ cwd=root_path,
109
+ capture_output=True,
110
+ text=True,
111
+ )
112
+ )
113
+
114
+ branches = []
115
+ for line in all_result.stdout.split("\n"):
116
+ if line:
117
+ branch_name = line.strip().replace("* ", "")
118
+ branches.append(branch_name)
119
+
120
+ return {
121
+ "current": current_branch,
122
+ "all": branches,
123
+ }
124
+
125
+ def get_schema(self) -> dict:
126
+ return {
127
+ "type": "object",
128
+ "properties": {
129
+ "directory": {
130
+ "type": "string",
131
+ "description": "Git repository directory",
132
+ },
133
+ },
134
+ }
velune/tools/module.py ADDED
@@ -0,0 +1,65 @@
1
+ from velune.kernel.bootstrap import RuntimeEnvironment, SubsystemModule
2
+
3
+
4
+ def _create_tool_registry(env: RuntimeEnvironment):
5
+ import logging
6
+
7
+ from velune.tools import (
8
+ CreateFile,
9
+ DeleteFile,
10
+ ExecuteCommand,
11
+ FindFiles,
12
+ FindReferences,
13
+ GitBlame,
14
+ GitBranch,
15
+ GitCheckout,
16
+ GitCommit,
17
+ GitDiff,
18
+ GitLog,
19
+ GitStatus,
20
+ GoToDefinition,
21
+ GrepFiles,
22
+ ReadDirectory,
23
+ ReadFile,
24
+ SemanticCodeSearch,
25
+ SymbolSearch,
26
+ TerminalHistory,
27
+ WebFetch,
28
+ WriteFile,
29
+ )
30
+ from velune.tools.base.registry import ToolRegistry
31
+
32
+ logger = logging.getLogger("velune.tools.module")
33
+
34
+ execution_executor = env.container.get("runtime.execution_executor")
35
+
36
+ tool_registry = ToolRegistry()
37
+ execute_cmd_tool = ExecuteCommand(
38
+ sandbox=execution_executor.sandbox,
39
+ workspace_path=str(env.workspace)
40
+ )
41
+ default_tools = [
42
+ ReadFile(workspace=env.workspace), ReadDirectory(workspace=env.workspace),
43
+ WriteFile(workspace=env.workspace), CreateFile(workspace=env.workspace),
44
+ DeleteFile(workspace=env.workspace),
45
+ GrepFiles(), FindFiles(), GitLog(), GitDiff(), GitBlame(), GitStatus(), GitBranch(),
46
+ GitCommit(), GitCheckout(), execute_cmd_tool, TerminalHistory(),
47
+ SemanticCodeSearch(), SymbolSearch(), GoToDefinition(), FindReferences(), WebFetch()
48
+ ]
49
+ for tool in default_tools:
50
+ tool_registry.register(tool)
51
+
52
+ broken = tool_registry.list_broken_tools()
53
+ if broken:
54
+ logger.warning("Tools failed validation: %s", broken)
55
+
56
+ return tool_registry
57
+
58
+ TOOL_MODULES = [
59
+ SubsystemModule(
60
+ name="tool_registry",
61
+ factory=_create_tool_registry,
62
+ container_key="runtime.tool_registry",
63
+ dependencies=["runtime.execution_executor"],
64
+ )
65
+ ]
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from velune.execution.sandbox import SubprocessSandbox
7
+
8
+ from velune.tools.base.tool import BaseTool
9
+
10
+
11
+ class ExecuteCommand(BaseTool):
12
+ """Tool for executing terminal commands."""
13
+
14
+ def __init__(self, sandbox: SubprocessSandbox | None = None, workspace_path: str | None = None):
15
+ self._sandbox = sandbox
16
+ self._workspace_path = workspace_path
17
+
18
+ def get_name(self) -> str:
19
+ return "execute_command"
20
+
21
+ def get_description(self) -> str:
22
+ return "Execute a terminal command"
23
+
24
+ async def execute(
25
+ self,
26
+ command: str,
27
+ directory: str | None = None,
28
+ timeout: int = 30,
29
+ ) -> dict:
30
+ """Execute a command."""
31
+ from pathlib import Path
32
+
33
+ from velune.core.errors.execution import SandboxError
34
+ from velune.execution.command_spec import CommandSpec
35
+ from velune.execution.sandbox import SubprocessSandbox
36
+
37
+ workspace = Path(directory or self._workspace_path or Path.cwd())
38
+ sandbox = self._sandbox or SubprocessSandbox(workspace)
39
+
40
+ try:
41
+ spec = CommandSpec.from_string(command, cwd=workspace, timeout=float(timeout))
42
+ except SandboxError as e:
43
+ sandbox.emit_rejection(command, str(e))
44
+ raise e
45
+
46
+ result = sandbox.execute(spec)
47
+ return {
48
+ "exit_code": result.exit_code,
49
+ "stdout": result.stdout,
50
+ "stderr": result.stderr,
51
+ "duration_ms": result.duration_ms,
52
+ }
53
+
54
+ def get_schema(self) -> dict:
55
+ return {
56
+ "type": "object",
57
+ "properties": {
58
+ "command": {
59
+ "type": "string",
60
+ "description": "Command to execute",
61
+ },
62
+ "directory": {
63
+ "type": "string",
64
+ "description": "Working directory",
65
+ },
66
+ "timeout": {
67
+ "type": "integer",
68
+ "description": "Command timeout in seconds",
69
+ },
70
+ },
71
+ "required": ["command"],
72
+ }
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from velune.tools.base.tool import BaseTool
6
+
7
+
8
+ class TerminalHistory(BaseTool):
9
+ """Tool for viewing terminal history."""
10
+
11
+ def get_name(self) -> str:
12
+ return "terminal_history"
13
+
14
+ def get_description(self) -> str:
15
+ return "View terminal command history"
16
+
17
+ async def execute(
18
+ self,
19
+ limit: int = 50,
20
+ ) -> list[str]:
21
+ """Get terminal history."""
22
+ history_file = Path.home() / ".bash_history"
23
+
24
+ if not history_file.exists():
25
+ history_file = Path.home() / ".zsh_history"
26
+
27
+ if not history_file.exists():
28
+ return []
29
+
30
+ with open(history_file, encoding="utf-8", errors="ignore") as f:
31
+ lines = f.readlines()
32
+
33
+ # Get last N lines
34
+ history = [line.strip() for line in lines[-limit:]]
35
+
36
+ return history
37
+
38
+ def get_schema(self) -> dict:
39
+ return {
40
+ "type": "object",
41
+ "properties": {
42
+ "limit": {
43
+ "type": "integer",
44
+ "description": "Number of history entries to return",
45
+ },
46
+ },
47
+ }
@@ -0,0 +1,55 @@
1
+ """Web fetch tools."""
2
+
3
+ from velune.tools.base.tool import BaseTool
4
+ from velune.tools.web.validator import validate_url
5
+
6
+
7
+ class WebFetch(BaseTool):
8
+ """Tool for fetching web content."""
9
+
10
+ def get_name(self) -> str:
11
+ return "web_fetch"
12
+
13
+ def get_description(self) -> str:
14
+ return "Fetch content from a URL"
15
+
16
+ async def execute(
17
+ self,
18
+ url: str,
19
+ timeout: int = 10,
20
+ ) -> str:
21
+ """Fetch content from URL."""
22
+ import httpx
23
+
24
+ is_valid, error = validate_url(url)
25
+ if not is_valid:
26
+ raise ValueError(f"URL validation failed: {error}")
27
+
28
+ async with httpx.AsyncClient(
29
+ timeout=timeout,
30
+ follow_redirects=True,
31
+ max_redirects=3, # Prevent redirect chains to internal services
32
+ ) as client:
33
+ response = await client.get(url)
34
+ response.raise_for_status()
35
+ # Limit response size to prevent memory exhaustion
36
+ content = response.text
37
+ if len(content) > 500_000: # 500KB limit
38
+ content = content[:500_000] + "\n... [TRUNCATED: response exceeded 500KB]"
39
+ return content
40
+
41
+ def get_schema(self) -> dict:
42
+ return {
43
+ "type": "object",
44
+ "properties": {
45
+ "url": {
46
+ "type": "string",
47
+ "description": "Fetch content from a URL. HTTPS only. Private/internal IPs blocked.",
48
+ },
49
+ "timeout": {
50
+ "type": "integer",
51
+ "description": "Request timeout in seconds",
52
+ },
53
+ },
54
+ "required": ["url"],
55
+ }
@@ -0,0 +1,96 @@
1
+ import ipaddress
2
+ import socket
3
+ from urllib.parse import urlparse
4
+
5
+ BLOCKED_HOSTS = frozenset({
6
+ "169.254.169.254", # AWS IMDS v1
7
+ "169.254.170.2", # AWS ECS metadata
8
+ "metadata.google.internal",
9
+ "metadata.goog",
10
+ "169.254.0.0", # link-local broadcast
11
+ })
12
+
13
+ BLOCKED_PREFIXES = (
14
+ "169.254.", # link-local range (catch-all)
15
+ )
16
+
17
+ def _is_private_ip(address: str) -> tuple[bool, str]:
18
+ """Returns (is_blocked, reason). Resolves hostnames."""
19
+ try:
20
+ ip = ipaddress.ip_address(address)
21
+ except ValueError:
22
+ # It's a hostname — resolve it
23
+ try:
24
+ socket.setdefaulttimeout(3)
25
+ resolved = socket.getaddrinfo(address, None, proto=socket.IPPROTO_TCP)
26
+ for family, _, _, _, sockaddr in resolved:
27
+ ip_str = sockaddr[0]
28
+ blocked, reason = _is_private_ip(ip_str)
29
+ if blocked:
30
+ return True, f"hostname {address!r} resolves to blocked IP {ip_str}: {reason}"
31
+ except socket.gaierror:
32
+ return False, "" # Can't resolve — let the request fail naturally
33
+ return False, ""
34
+
35
+ if ip.is_loopback:
36
+ return True, "loopback address"
37
+ if ip.is_private:
38
+ return True, "private network range"
39
+ if ip.is_link_local:
40
+ return True, "link-local address (fe80::/10 or 169.254.x.x)"
41
+ if ip.is_reserved:
42
+ return True, "reserved address"
43
+ if ip.is_multicast:
44
+ return True, "multicast address"
45
+ # Explicitly check for 0.0.0.0/8
46
+ if isinstance(ip, ipaddress.IPv4Address) and ip in ipaddress.ip_network("0.0.0.0/8"):
47
+ return True, "unspecified address range"
48
+ return False, ""
49
+
50
+ def validate_url(url: str, allow_http: bool = False) -> tuple[bool, str | None]:
51
+ """
52
+ Validate URL for SSRF safety.
53
+ Returns (is_valid, error_message).
54
+ """
55
+ try:
56
+ parsed = urlparse(url)
57
+ except Exception:
58
+ return False, "Unparseable URL"
59
+
60
+ # Reject credentials in URL
61
+ if parsed.username or parsed.password:
62
+ return False, "URLs with embedded credentials are not allowed"
63
+
64
+ if parsed.scheme not in ("https", "http"):
65
+ return False, f"Scheme '{parsed.scheme}' not allowed"
66
+ if parsed.scheme == "http" and not allow_http:
67
+ return False, "HTTP not allowed — use HTTPS"
68
+
69
+ hostname = parsed.hostname
70
+ if not hostname:
71
+ return False, "No hostname"
72
+ hostname = hostname.lower().strip(".")
73
+
74
+ if hostname in BLOCKED_HOSTS:
75
+ return False, f"Host '{hostname}' is explicitly blocked"
76
+ for prefix in BLOCKED_PREFIXES:
77
+ if hostname.startswith(prefix):
78
+ return False, f"Host '{hostname}' matches blocked prefix"
79
+
80
+ # Reject numeric escape forms (0x7f000001, 0177.0.0.1, decimal integer, etc.)
81
+ # We use socket.inet_aton which handles hex, octal, decimal, integer, and multi-dot notations
82
+ # exactly the way the OS/socket libraries parse numeric IPv4 addresses.
83
+ try:
84
+ packed = socket.inet_aton(hostname)
85
+ ip_str = socket.inet_ntoa(packed)
86
+ blocked, reason = _is_private_ip(ip_str)
87
+ if blocked:
88
+ return False, f"Numeric IP {hostname} resolves to blocked: {reason}"
89
+ except OSError:
90
+ pass
91
+
92
+ blocked, reason = _is_private_ip(hostname)
93
+ if blocked:
94
+ return False, reason
95
+
96
+ return True, None