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.
- velune/__init__.py +5 -0
- velune/__main__.py +6 -0
- velune/cli/__init__.py +5 -0
- velune/cli/app.py +212 -0
- velune/cli/autocomplete.py +76 -0
- velune/cli/banner.py +98 -0
- velune/cli/commands/__init__.py +32 -0
- velune/cli/commands/ask.py +149 -0
- velune/cli/commands/base.py +16 -0
- velune/cli/commands/chat.py +188 -0
- velune/cli/commands/config.py +182 -0
- velune/cli/commands/daemon.py +85 -0
- velune/cli/commands/doctor.py +373 -0
- velune/cli/commands/init.py +160 -0
- velune/cli/commands/mcp.py +80 -0
- velune/cli/commands/memory.py +269 -0
- velune/cli/commands/models.py +462 -0
- velune/cli/commands/preflight.py +95 -0
- velune/cli/commands/run.py +171 -0
- velune/cli/commands/setup.py +182 -0
- velune/cli/commands/workspace.py +217 -0
- velune/cli/context.py +37 -0
- velune/cli/councilmodel_ui.py +171 -0
- velune/cli/display/council_view.py +240 -0
- velune/cli/display/memory_view.py +93 -0
- velune/cli/display/panels.py +35 -0
- velune/cli/display/progress.py +25 -0
- velune/cli/display/themes.py +21 -0
- velune/cli/main.py +15 -0
- velune/cli/model_selector.py +44 -0
- velune/cli/modes.py +86 -0
- velune/cli/pull_ui.py +118 -0
- velune/cli/registry.py +81 -0
- velune/cli/repl.py +1178 -0
- velune/cli/session_manager.py +69 -0
- velune/cli/slash_commands.py +37 -0
- velune/cognition/__init__.py +19 -0
- velune/cognition/arbitrator.py +216 -0
- velune/cognition/architecture.py +398 -0
- velune/cognition/council/__init__.py +47 -0
- velune/cognition/council/base.py +216 -0
- velune/cognition/council/challenger.py +70 -0
- velune/cognition/council/coder.py +79 -0
- velune/cognition/council/critic_agent.py +39 -0
- velune/cognition/council/critic_configs.py +111 -0
- velune/cognition/council/critics.py +41 -0
- velune/cognition/council/debate.py +44 -0
- velune/cognition/council/factory.py +140 -0
- velune/cognition/council/messages.py +53 -0
- velune/cognition/council/planner.py +119 -0
- velune/cognition/council/reviewer.py +72 -0
- velune/cognition/council/synthesizer.py +67 -0
- velune/cognition/council/tiers.py +181 -0
- velune/cognition/firewall.py +256 -0
- velune/cognition/module.py +38 -0
- velune/cognition/orchestrator.py +886 -0
- velune/cognition/personality.py +236 -0
- velune/cognition/style_resolver.py +62 -0
- velune/cognition/verification.py +201 -0
- velune/context/__init__.py +11 -0
- velune/context/extractive.py +94 -0
- velune/context/window.py +62 -0
- velune/core/__init__.py +89 -0
- velune/core/background.py +5 -0
- velune/core/config/__init__.py +37 -0
- velune/core/errors/__init__.py +53 -0
- velune/core/errors/execution.py +26 -0
- velune/core/errors/memory.py +21 -0
- velune/core/errors/orchestration.py +26 -0
- velune/core/errors/provider.py +31 -0
- velune/core/event_loop.py +30 -0
- velune/core/logging.py +83 -0
- velune/core/runtime.py +106 -0
- velune/core/task_registry.py +120 -0
- velune/core/trace.py +80 -0
- velune/core/types/__init__.py +48 -0
- velune/core/types/agent.py +49 -0
- velune/core/types/context.py +39 -0
- velune/core/types/inference.py +35 -0
- velune/core/types/memory.py +39 -0
- velune/core/types/model.py +64 -0
- velune/core/types/provider.py +35 -0
- velune/core/types/repository.py +35 -0
- velune/core/types/task.py +56 -0
- velune/core/types/workspace.py +28 -0
- velune/daemon/client.py +13 -0
- velune/daemon/server.py +115 -0
- velune/daemon/transport.py +169 -0
- velune/events.py +194 -0
- velune/execution/__init__.py +22 -0
- velune/execution/benchmarker.py +311 -0
- velune/execution/cancellation.py +53 -0
- velune/execution/checkpointer.py +128 -0
- velune/execution/command_spec.py +140 -0
- velune/execution/diff_preview.py +172 -0
- velune/execution/executor.py +173 -0
- velune/execution/module.py +16 -0
- velune/execution/multi_diff.py +70 -0
- velune/execution/path_guard.py +19 -0
- velune/execution/planner.py +91 -0
- velune/execution/rollback.py +80 -0
- velune/execution/sandbox.py +257 -0
- velune/execution/validator.py +113 -0
- velune/hardware/__init__.py +1 -0
- velune/hardware/detector.py +162 -0
- velune/kernel/__init__.py +58 -0
- velune/kernel/bootstrap.py +107 -0
- velune/kernel/config.py +252 -0
- velune/kernel/health.py +54 -0
- velune/kernel/lifecycle.py +102 -0
- velune/kernel/module.py +15 -0
- velune/kernel/modules.py +23 -0
- velune/kernel/registry.py +93 -0
- velune/kernel/schemas.py +28 -0
- velune/main.py +9 -0
- velune/mcp/__init__.py +9 -0
- velune/mcp/client.py +113 -0
- velune/mcp/config.py +19 -0
- velune/mcp/server.py +90 -0
- velune/memory/__init__.py +28 -0
- velune/memory/lifecycle.py +154 -0
- velune/memory/module.py +94 -0
- velune/memory/prioritizer.py +65 -0
- velune/memory/storage/sqlite_manager.py +368 -0
- velune/memory/tiers/episodic.py +156 -0
- velune/memory/tiers/graph.py +282 -0
- velune/memory/tiers/lineage.py +367 -0
- velune/memory/tiers/semantic.py +198 -0
- velune/memory/tiers/working.py +165 -0
- velune/models/__init__.py +16 -0
- velune/models/module.py +18 -0
- velune/models/probes.py +182 -0
- velune/models/profile_cache.py +82 -0
- velune/models/profiler.py +105 -0
- velune/models/registry.py +201 -0
- velune/models/scorer.py +225 -0
- velune/models/specializations.py +196 -0
- velune/orchestration/__init__.py +15 -0
- velune/orchestration/module.py +14 -0
- velune/orchestration/role_assignments.py +78 -0
- velune/orchestration/schemas.py +99 -0
- velune/plugins/__init__.py +13 -0
- velune/plugins/hooks.py +49 -0
- velune/plugins/loader.py +95 -0
- velune/plugins/registry.py +54 -0
- velune/plugins/schemas.py +19 -0
- velune/providers/__init__.py +18 -0
- velune/providers/adapters/anthropic.py +231 -0
- velune/providers/adapters/fireworks.py +115 -0
- velune/providers/adapters/google.py +234 -0
- velune/providers/adapters/groq.py +151 -0
- velune/providers/adapters/huggingface.py +203 -0
- velune/providers/adapters/llamacpp.py +202 -0
- velune/providers/adapters/lmstudio.py +173 -0
- velune/providers/adapters/ollama.py +186 -0
- velune/providers/adapters/openai.py +207 -0
- velune/providers/adapters/openrouter.py +81 -0
- velune/providers/adapters/together.py +134 -0
- velune/providers/adapters/xai.py +60 -0
- velune/providers/base.py +86 -0
- velune/providers/benchmarker.py +135 -0
- velune/providers/discovery/__init__.py +33 -0
- velune/providers/discovery/anthropic.py +77 -0
- velune/providers/discovery/benchmarks.py +44 -0
- velune/providers/discovery/classifier.py +69 -0
- velune/providers/discovery/fireworks.py +95 -0
- velune/providers/discovery/gguf.py +87 -0
- velune/providers/discovery/google.py +95 -0
- velune/providers/discovery/gpu.py +109 -0
- velune/providers/discovery/groq.py +20 -0
- velune/providers/discovery/huggingface.py +67 -0
- velune/providers/discovery/lmstudio.py +80 -0
- velune/providers/discovery/ollama.py +165 -0
- velune/providers/discovery/openai.py +96 -0
- velune/providers/discovery/openrouter.py +113 -0
- velune/providers/discovery/scanner.py +114 -0
- velune/providers/discovery/together.py +114 -0
- velune/providers/discovery/xai.py +57 -0
- velune/providers/keystore.py +83 -0
- velune/providers/local_paths.py +49 -0
- velune/providers/local_resolver.py +208 -0
- velune/providers/module.py +15 -0
- velune/providers/ollama_manager.py +193 -0
- velune/providers/registry.py +173 -0
- velune/providers/router.py +82 -0
- velune/py.typed +0 -0
- velune/repository/__init__.py +21 -0
- velune/repository/analyzer.py +123 -0
- velune/repository/cognition.py +172 -0
- velune/repository/grapher.py +182 -0
- velune/repository/indexer.py +229 -0
- velune/repository/module.py +15 -0
- velune/repository/parser.py +378 -0
- velune/repository/project_type.py +293 -0
- velune/repository/scanner.py +177 -0
- velune/repository/schemas.py +102 -0
- velune/repository/tracker.py +233 -0
- velune/retrieval/__init__.py +27 -0
- velune/retrieval/graph.py +117 -0
- velune/retrieval/hybrid.py +250 -0
- velune/retrieval/keyword.py +113 -0
- velune/retrieval/module.py +19 -0
- velune/retrieval/reranker.py +68 -0
- velune/retrieval/schemas.py +59 -0
- velune/retrieval/vector.py +163 -0
- velune/telemetry/__init__.py +7 -0
- velune/telemetry/cognition.py +260 -0
- velune/telemetry/token_tracker.py +133 -0
- velune/tools/__init__.py +41 -0
- velune/tools/base/registry.py +84 -0
- velune/tools/base/tool.py +63 -0
- velune/tools/code/navigate.py +107 -0
- velune/tools/code/search.py +112 -0
- velune/tools/filesystem/read.py +75 -0
- velune/tools/filesystem/search.py +123 -0
- velune/tools/filesystem/write.py +160 -0
- velune/tools/git/history.py +185 -0
- velune/tools/git/operations.py +132 -0
- velune/tools/git/state.py +134 -0
- velune/tools/module.py +65 -0
- velune/tools/terminal/execute.py +72 -0
- velune/tools/terminal/history.py +47 -0
- velune/tools/web/fetch.py +55 -0
- velune/tools/web/validator.py +96 -0
- velune_cli-1.0.0.dist-info/METADATA +497 -0
- velune_cli-1.0.0.dist-info/RECORD +229 -0
- velune_cli-1.0.0.dist-info/WHEEL +4 -0
- velune_cli-1.0.0.dist-info/entry_points.txt +2 -0
- 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
|