velune-cli 0.9.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 +208 -0
- velune/cli/autocomplete.py +80 -0
- velune/cli/banner.py +60 -0
- velune/cli/commands/__init__.py +32 -0
- velune/cli/commands/ask.py +175 -0
- velune/cli/commands/base.py +16 -0
- velune/cli/commands/chat.py +228 -0
- velune/cli/commands/config.py +224 -0
- velune/cli/commands/daemon.py +88 -0
- velune/cli/commands/doctor.py +721 -0
- velune/cli/commands/init.py +170 -0
- velune/cli/commands/mcp.py +82 -0
- velune/cli/commands/memory.py +293 -0
- velune/cli/commands/models.py +683 -0
- velune/cli/commands/preflight.py +95 -0
- velune/cli/commands/run.py +270 -0
- velune/cli/commands/setup.py +184 -0
- velune/cli/commands/workspace.py +249 -0
- velune/cli/context.py +36 -0
- velune/cli/councilmodel_ui.py +199 -0
- velune/cli/display/council_view.py +254 -0
- velune/cli/display/memory_view.py +126 -0
- velune/cli/display/panels.py +35 -0
- velune/cli/display/progress.py +25 -0
- velune/cli/display/themes.py +25 -0
- velune/cli/main.py +15 -0
- velune/cli/model_selector.py +51 -0
- velune/cli/modes.py +86 -0
- velune/cli/pull_ui.py +123 -0
- velune/cli/registry.py +80 -0
- velune/cli/rendering/__init__.py +5 -0
- velune/cli/rendering/error_panel.py +79 -0
- velune/cli/rendering/markdown.py +63 -0
- velune/cli/repl.py +1855 -0
- velune/cli/session_manager.py +71 -0
- velune/cli/slash_commands.py +37 -0
- velune/cli/theme.py +8 -0
- velune/cognition/__init__.py +23 -0
- velune/cognition/agents/__init__.py +7 -0
- velune/cognition/agents/coder.py +209 -0
- velune/cognition/agents/planner.py +156 -0
- velune/cognition/agents/reviewer.py +195 -0
- velune/cognition/arbitrator.py +220 -0
- velune/cognition/architecture.py +415 -0
- velune/cognition/budget.py +65 -0
- velune/cognition/council/__init__.py +47 -0
- velune/cognition/council/base.py +217 -0
- velune/cognition/council/challenger.py +74 -0
- velune/cognition/council/coder.py +79 -0
- velune/cognition/council/critic_agent.py +43 -0
- velune/cognition/council/critic_configs.py +111 -0
- velune/cognition/council/critics.py +41 -0
- velune/cognition/council/debate.py +46 -0
- velune/cognition/council/factory.py +140 -0
- velune/cognition/council/messages.py +56 -0
- velune/cognition/council/planner.py +124 -0
- velune/cognition/council/reviewer.py +74 -0
- velune/cognition/council/synthesizer.py +67 -0
- velune/cognition/council/tiers.py +188 -0
- velune/cognition/council_orchestrator.py +282 -0
- velune/cognition/firewall.py +354 -0
- velune/cognition/module.py +46 -0
- velune/cognition/orchestrator.py +1205 -0
- velune/cognition/personality.py +238 -0
- velune/cognition/state.py +104 -0
- velune/cognition/style_resolver.py +64 -0
- velune/cognition/verification.py +205 -0
- velune/context/__init__.py +28 -0
- velune/context/assembler.py +240 -0
- velune/context/budget.py +97 -0
- velune/context/extractive.py +95 -0
- velune/context/prompt_adaptation.py +480 -0
- velune/context/sections.py +99 -0
- velune/context/token_counter.py +134 -0
- velune/context/utilization.py +33 -0
- velune/context/window.py +63 -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 +90 -0
- velune/core/errors/catalog.py +188 -0
- velune/core/errors/execution.py +31 -0
- velune/core/errors/memory.py +25 -0
- velune/core/errors/orchestration.py +31 -0
- velune/core/errors/provider.py +37 -0
- velune/core/event_loop.py +35 -0
- velune/core/logging.py +83 -0
- velune/core/paths.py +165 -0
- velune/core/runtime.py +113 -0
- velune/core/startup_profiler.py +56 -0
- velune/core/task_registry.py +117 -0
- velune/core/trace.py +83 -0
- velune/core/types/__init__.py +48 -0
- velune/core/types/agent.py +53 -0
- velune/core/types/context.py +42 -0
- velune/core/types/inference.py +38 -0
- velune/core/types/memory.py +42 -0
- velune/core/types/model.py +70 -0
- velune/core/types/provider.py +62 -0
- velune/core/types/repository.py +38 -0
- velune/core/types/task.py +61 -0
- velune/core/types/workspace.py +28 -0
- velune/daemon/client.py +13 -0
- velune/daemon/server.py +127 -0
- velune/daemon/transport.py +179 -0
- velune/events.py +204 -0
- velune/execution/__init__.py +22 -0
- velune/execution/benchmarker.py +315 -0
- velune/execution/cancellation.py +53 -0
- velune/execution/checkpointer.py +130 -0
- velune/execution/command_spec.py +165 -0
- velune/execution/diff_preview.py +197 -0
- velune/execution/executor.py +181 -0
- velune/execution/module.py +18 -0
- velune/execution/multi_diff.py +67 -0
- velune/execution/path_guard.py +74 -0
- velune/execution/planner.py +91 -0
- velune/execution/rollback.py +89 -0
- velune/execution/sandbox.py +268 -0
- velune/execution/validator.py +115 -0
- velune/hardware/__init__.py +1 -0
- velune/hardware/detector.py +192 -0
- velune/kernel/__init__.py +55 -0
- velune/kernel/bootstrap.py +125 -0
- velune/kernel/config.py +426 -0
- velune/kernel/entrypoint.py +78 -0
- velune/kernel/health.py +54 -0
- velune/kernel/lifecycle.py +143 -0
- velune/kernel/module.py +17 -0
- velune/kernel/modules.py +23 -0
- velune/kernel/registry.py +96 -0
- velune/kernel/schemas.py +28 -0
- velune/main.py +9 -0
- velune/mcp/__init__.py +9 -0
- velune/mcp/client.py +115 -0
- velune/mcp/config.py +19 -0
- velune/mcp/server.py +624 -0
- velune/memory/__init__.py +32 -0
- velune/memory/compaction.py +506 -0
- velune/memory/embedding_pipeline.py +241 -0
- velune/memory/lifecycle.py +680 -0
- velune/memory/module.py +218 -0
- velune/memory/prioritizer.py +67 -0
- velune/memory/storage/episodic_schema.sql +53 -0
- velune/memory/storage/lancedb_store.py +282 -0
- velune/memory/storage/sqlite_manager.py +369 -0
- velune/memory/storage/sqlite_pool.py +149 -0
- velune/memory/tiers/episodic.py +588 -0
- velune/memory/tiers/graph.py +378 -0
- velune/memory/tiers/lineage.py +416 -0
- velune/memory/tiers/semantic.py +475 -0
- velune/memory/tiers/working.py +168 -0
- velune/memory/vitality.py +132 -0
- velune/models/__init__.py +15 -0
- velune/models/family.py +76 -0
- velune/models/module.py +20 -0
- velune/models/probes.py +192 -0
- velune/models/profile_cache.py +84 -0
- velune/models/profiler.py +108 -0
- velune/models/registry.py +251 -0
- velune/models/scorer.py +233 -0
- velune/models/specializations.py +205 -0
- velune/orchestration/__init__.py +19 -0
- velune/orchestration/engine.py +239 -0
- velune/orchestration/module.py +15 -0
- velune/orchestration/role_assignments.py +82 -0
- velune/orchestration/schemas.py +98 -0
- velune/plugins/__init__.py +20 -0
- velune/plugins/hooks.py +50 -0
- velune/plugins/loader.py +161 -0
- velune/plugins/registry.py +56 -0
- velune/plugins/schemas.py +21 -0
- velune/providers/__init__.py +23 -0
- velune/providers/adapters/anthropic.py +257 -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 +210 -0
- velune/providers/adapters/llamacpp.py +208 -0
- velune/providers/adapters/lmstudio.py +175 -0
- velune/providers/adapters/ollama.py +233 -0
- velune/providers/adapters/openai.py +213 -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 +138 -0
- velune/providers/discovery/__init__.py +33 -0
- velune/providers/discovery/anthropic.py +79 -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 +88 -0
- velune/providers/discovery/google.py +95 -0
- velune/providers/discovery/gpu.py +117 -0
- velune/providers/discovery/groq.py +21 -0
- velune/providers/discovery/huggingface.py +67 -0
- velune/providers/discovery/lmstudio.py +80 -0
- velune/providers/discovery/ollama.py +162 -0
- velune/providers/discovery/openai.py +96 -0
- velune/providers/discovery/openrouter.py +113 -0
- velune/providers/discovery/scanner.py +115 -0
- velune/providers/discovery/together.py +114 -0
- velune/providers/discovery/xai.py +57 -0
- velune/providers/health.py +67 -0
- velune/providers/health_monitor.py +169 -0
- velune/providers/keystore.py +142 -0
- velune/providers/local_paths.py +49 -0
- velune/providers/local_resolver.py +229 -0
- velune/providers/module.py +51 -0
- velune/providers/ollama_manager.py +193 -0
- velune/providers/registry.py +220 -0
- velune/providers/router.py +255 -0
- velune/providers/task_classifier.py +288 -0
- velune/py.typed +0 -0
- velune/repository/__init__.py +33 -0
- velune/repository/analyzer.py +127 -0
- velune/repository/ast_parser.py +822 -0
- velune/repository/blast_radius.py +298 -0
- velune/repository/boundary_classifier.py +295 -0
- velune/repository/cognition.py +316 -0
- velune/repository/grapher.py +179 -0
- velune/repository/import_graph.py +263 -0
- velune/repository/incremental_indexer.py +275 -0
- velune/repository/index_state.py +96 -0
- velune/repository/indexer.py +243 -0
- velune/repository/module.py +17 -0
- velune/repository/parser.py +474 -0
- velune/repository/project_type.py +300 -0
- velune/repository/rename_journal.py +287 -0
- velune/repository/scanner.py +193 -0
- velune/repository/schemas.py +102 -0
- velune/repository/symbol_registry.py +365 -0
- velune/repository/tracker.py +252 -0
- velune/retrieval/__init__.py +27 -0
- velune/retrieval/cache.py +110 -0
- velune/retrieval/fast_path.py +391 -0
- velune/retrieval/graph.py +124 -0
- velune/retrieval/hybrid.py +271 -0
- velune/retrieval/keyword.py +131 -0
- velune/retrieval/module.py +26 -0
- velune/retrieval/pipeline.py +303 -0
- velune/retrieval/reranker.py +102 -0
- velune/retrieval/schemas.py +59 -0
- velune/retrieval/slow_path.py +364 -0
- velune/retrieval/vector.py +203 -0
- velune/telemetry/__init__.py +59 -0
- velune/telemetry/cognition.py +267 -0
- velune/telemetry/cost_estimator.py +92 -0
- velune/telemetry/debug.py +304 -0
- velune/telemetry/doctor.py +244 -0
- velune/telemetry/logging.py +286 -0
- velune/telemetry/spans.py +277 -0
- velune/telemetry/token_tracker.py +140 -0
- velune/telemetry/usage_tracker.py +340 -0
- velune/tools/__init__.py +41 -0
- velune/tools/base/registry.py +87 -0
- velune/tools/base/tool.py +63 -0
- velune/tools/code/navigate.py +116 -0
- velune/tools/code/search.py +123 -0
- velune/tools/filesystem/read.py +75 -0
- velune/tools/filesystem/search.py +136 -0
- velune/tools/filesystem/write.py +163 -0
- velune/tools/git/history.py +177 -0
- velune/tools/git/operations.py +122 -0
- velune/tools/git/state.py +121 -0
- velune/tools/module.py +81 -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 +122 -0
- velune_cli-0.9.0.dist-info/METADATA +518 -0
- velune_cli-0.9.0.dist-info/RECORD +279 -0
- velune_cli-0.9.0.dist-info/WHEEL +4 -0
- velune_cli-0.9.0.dist-info/entry_points.txt +2 -0
- velune_cli-0.9.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Git operation tools — GitCommit, GitCheckout.
|
|
2
|
+
|
|
3
|
+
Uses gitpython's high-level API instead of raw subprocess. This eliminates
|
|
4
|
+
two classes of injection risk:
|
|
5
|
+
- Shell injection (subprocess with a shell argument is not used; gitpython handles this).
|
|
6
|
+
- Argument injection: branch names are looked up by key in the Repo heads
|
|
7
|
+
dict rather than spliced into a command string, so ``--detach`` or similar
|
|
8
|
+
flag-like inputs cannot influence git's option parsing.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from velune.execution.path_guard import PathGuard
|
|
17
|
+
from velune.tools.base.tool import BaseTool
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _open_repo(path: Path): # type: ignore[return]
|
|
21
|
+
try:
|
|
22
|
+
import git
|
|
23
|
+
|
|
24
|
+
return git.Repo(str(path), search_parent_directories=True)
|
|
25
|
+
except Exception as exc:
|
|
26
|
+
raise ValueError(f"Not a git repository: {path}") from exc
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _validate_ref_name(name: str, label: str = "name") -> None:
|
|
30
|
+
"""Reject ref names that look like git option flags."""
|
|
31
|
+
if name.startswith("-"):
|
|
32
|
+
raise ValueError(f"Invalid git {label} '{name}': names must not start with '-'.")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class GitCommit(BaseTool):
|
|
36
|
+
"""Tool for committing changes."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, workspace: Path | None = None) -> None:
|
|
39
|
+
self.workspace = Path(workspace).resolve() if workspace else Path.cwd().resolve()
|
|
40
|
+
|
|
41
|
+
def get_name(self) -> str:
|
|
42
|
+
return "git_commit"
|
|
43
|
+
|
|
44
|
+
def get_description(self) -> str:
|
|
45
|
+
return "Commit changes to git"
|
|
46
|
+
|
|
47
|
+
async def execute(
|
|
48
|
+
self,
|
|
49
|
+
message: str,
|
|
50
|
+
directory: str = ".",
|
|
51
|
+
add_all: bool = True,
|
|
52
|
+
) -> str:
|
|
53
|
+
guard = PathGuard(self.workspace)
|
|
54
|
+
safe_root = guard.validate(directory)
|
|
55
|
+
repo = _open_repo(safe_root)
|
|
56
|
+
|
|
57
|
+
def _do_commit() -> str:
|
|
58
|
+
if add_all:
|
|
59
|
+
repo.git.add(A=True)
|
|
60
|
+
commit = repo.index.commit(message)
|
|
61
|
+
return f"Committed: {message} ({commit.hexsha[:8]})"
|
|
62
|
+
|
|
63
|
+
return await asyncio.to_thread(_do_commit)
|
|
64
|
+
|
|
65
|
+
def get_schema(self) -> dict:
|
|
66
|
+
return {
|
|
67
|
+
"type": "object",
|
|
68
|
+
"properties": {
|
|
69
|
+
"message": {"type": "string", "description": "Commit message"},
|
|
70
|
+
"directory": {"type": "string", "description": "Git repository directory"},
|
|
71
|
+
"add_all": {"type": "boolean", "description": "Add all changes before committing"},
|
|
72
|
+
},
|
|
73
|
+
"required": ["message"],
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class GitCheckout(BaseTool):
|
|
78
|
+
"""Tool for checking out branches."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, workspace: Path | None = None) -> None:
|
|
81
|
+
self.workspace = Path(workspace).resolve() if workspace else Path.cwd().resolve()
|
|
82
|
+
|
|
83
|
+
def get_name(self) -> str:
|
|
84
|
+
return "git_checkout"
|
|
85
|
+
|
|
86
|
+
def get_description(self) -> str:
|
|
87
|
+
return "Checkout a git branch"
|
|
88
|
+
|
|
89
|
+
async def execute(
|
|
90
|
+
self,
|
|
91
|
+
branch: str,
|
|
92
|
+
directory: str = ".",
|
|
93
|
+
create: bool = False,
|
|
94
|
+
) -> str:
|
|
95
|
+
_validate_ref_name(branch, label="branch")
|
|
96
|
+
guard = PathGuard(self.workspace)
|
|
97
|
+
safe_root = guard.validate(directory)
|
|
98
|
+
repo = _open_repo(safe_root)
|
|
99
|
+
|
|
100
|
+
def _do_checkout() -> str:
|
|
101
|
+
if create:
|
|
102
|
+
new_head = repo.create_head(branch)
|
|
103
|
+
new_head.checkout()
|
|
104
|
+
else:
|
|
105
|
+
existing = {h.name: h for h in repo.heads}
|
|
106
|
+
if branch not in existing:
|
|
107
|
+
raise ValueError(f"Branch not found: '{branch}'")
|
|
108
|
+
existing[branch].checkout()
|
|
109
|
+
return f"Checked out: {branch}"
|
|
110
|
+
|
|
111
|
+
return await asyncio.to_thread(_do_checkout)
|
|
112
|
+
|
|
113
|
+
def get_schema(self) -> dict:
|
|
114
|
+
return {
|
|
115
|
+
"type": "object",
|
|
116
|
+
"properties": {
|
|
117
|
+
"branch": {"type": "string", "description": "Branch name"},
|
|
118
|
+
"directory": {"type": "string", "description": "Git repository directory"},
|
|
119
|
+
"create": {"type": "boolean", "description": "Create branch if it doesn't exist"},
|
|
120
|
+
},
|
|
121
|
+
"required": ["branch"],
|
|
122
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Git state tools — GitStatus, GitBranch.
|
|
2
|
+
|
|
3
|
+
Uses gitpython instead of raw subprocess calls. PathGuard validates the
|
|
4
|
+
directory parameter so the tools cannot be pointed outside the workspace.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from velune.execution.path_guard import PathGuard
|
|
13
|
+
from velune.tools.base.tool import BaseTool
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _open_repo(path: Path): # type: ignore[return]
|
|
17
|
+
try:
|
|
18
|
+
import git
|
|
19
|
+
|
|
20
|
+
return git.Repo(str(path), search_parent_directories=True)
|
|
21
|
+
except Exception as exc:
|
|
22
|
+
raise ValueError(f"Not a git repository: {path}") from exc
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class GitStatus(BaseTool):
|
|
26
|
+
"""Tool for viewing git status."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, workspace: Path | None = None) -> None:
|
|
29
|
+
self.workspace = Path(workspace).resolve() if workspace else Path.cwd().resolve()
|
|
30
|
+
|
|
31
|
+
def get_name(self) -> str:
|
|
32
|
+
return "git_status"
|
|
33
|
+
|
|
34
|
+
def get_description(self) -> str:
|
|
35
|
+
return "View git repository status"
|
|
36
|
+
|
|
37
|
+
async def execute(self, directory: str = ".") -> dict:
|
|
38
|
+
guard = PathGuard(self.workspace)
|
|
39
|
+
safe_root = guard.validate(directory)
|
|
40
|
+
repo = _open_repo(safe_root)
|
|
41
|
+
|
|
42
|
+
def _fetch() -> dict:
|
|
43
|
+
status: dict[str, list[str]] = {
|
|
44
|
+
"modified": [],
|
|
45
|
+
"added": [],
|
|
46
|
+
"deleted": [],
|
|
47
|
+
"untracked": [],
|
|
48
|
+
}
|
|
49
|
+
# Staged changes (index vs HEAD)
|
|
50
|
+
try:
|
|
51
|
+
for diff in repo.index.diff("HEAD"):
|
|
52
|
+
if diff.change_type == "M":
|
|
53
|
+
status["modified"].append(diff.b_path or diff.a_path)
|
|
54
|
+
elif diff.change_type == "A":
|
|
55
|
+
status["added"].append(diff.b_path)
|
|
56
|
+
elif diff.change_type == "D":
|
|
57
|
+
status["deleted"].append(diff.a_path)
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|
|
60
|
+
# Unstaged changes (working tree vs index)
|
|
61
|
+
try:
|
|
62
|
+
for diff in repo.index.diff(None):
|
|
63
|
+
path = diff.a_path
|
|
64
|
+
if diff.change_type == "M" and path not in status["modified"]:
|
|
65
|
+
status["modified"].append(path)
|
|
66
|
+
elif diff.change_type == "D" and path not in status["deleted"]:
|
|
67
|
+
status["deleted"].append(path)
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
status["untracked"] = list(repo.untracked_files)
|
|
71
|
+
return status
|
|
72
|
+
|
|
73
|
+
return await asyncio.to_thread(_fetch)
|
|
74
|
+
|
|
75
|
+
def get_schema(self) -> dict:
|
|
76
|
+
return {
|
|
77
|
+
"type": "object",
|
|
78
|
+
"properties": {
|
|
79
|
+
"directory": {"type": "string", "description": "Git repository directory"},
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class GitBranch(BaseTool):
|
|
85
|
+
"""Tool for viewing git branches."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, workspace: Path | None = None) -> None:
|
|
88
|
+
self.workspace = Path(workspace).resolve() if workspace else Path.cwd().resolve()
|
|
89
|
+
|
|
90
|
+
def get_name(self) -> str:
|
|
91
|
+
return "git_branch"
|
|
92
|
+
|
|
93
|
+
def get_description(self) -> str:
|
|
94
|
+
return "View git branches"
|
|
95
|
+
|
|
96
|
+
async def execute(self, directory: str = ".") -> dict:
|
|
97
|
+
guard = PathGuard(self.workspace)
|
|
98
|
+
safe_root = guard.validate(directory)
|
|
99
|
+
repo = _open_repo(safe_root)
|
|
100
|
+
|
|
101
|
+
def _fetch() -> dict:
|
|
102
|
+
try:
|
|
103
|
+
current = repo.active_branch.name
|
|
104
|
+
except TypeError:
|
|
105
|
+
current = "(detached HEAD)"
|
|
106
|
+
branches = [h.name for h in repo.heads]
|
|
107
|
+
remote_branches = [ref.name for ref in repo.remote_refs] if repo.remotes else []
|
|
108
|
+
return {
|
|
109
|
+
"current": current,
|
|
110
|
+
"all": branches + remote_branches,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return await asyncio.to_thread(_fetch)
|
|
114
|
+
|
|
115
|
+
def get_schema(self) -> dict:
|
|
116
|
+
return {
|
|
117
|
+
"type": "object",
|
|
118
|
+
"properties": {
|
|
119
|
+
"directory": {"type": "string", "description": "Git repository directory"},
|
|
120
|
+
},
|
|
121
|
+
}
|
velune/tools/module.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
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, workspace_path=str(env.workspace)
|
|
39
|
+
)
|
|
40
|
+
ws = env.workspace
|
|
41
|
+
default_tools = [
|
|
42
|
+
ReadFile(workspace=ws),
|
|
43
|
+
ReadDirectory(workspace=ws),
|
|
44
|
+
WriteFile(workspace=ws),
|
|
45
|
+
CreateFile(workspace=ws),
|
|
46
|
+
DeleteFile(workspace=ws),
|
|
47
|
+
GrepFiles(workspace=ws),
|
|
48
|
+
FindFiles(workspace=ws),
|
|
49
|
+
GitLog(workspace=ws),
|
|
50
|
+
GitDiff(workspace=ws),
|
|
51
|
+
GitBlame(workspace=ws),
|
|
52
|
+
GitStatus(workspace=ws),
|
|
53
|
+
GitBranch(workspace=ws),
|
|
54
|
+
GitCommit(workspace=ws),
|
|
55
|
+
GitCheckout(workspace=ws),
|
|
56
|
+
execute_cmd_tool,
|
|
57
|
+
TerminalHistory(),
|
|
58
|
+
SemanticCodeSearch(workspace=ws),
|
|
59
|
+
SymbolSearch(workspace=ws),
|
|
60
|
+
GoToDefinition(workspace=ws),
|
|
61
|
+
FindReferences(workspace=ws),
|
|
62
|
+
WebFetch(),
|
|
63
|
+
]
|
|
64
|
+
for tool in default_tools:
|
|
65
|
+
tool_registry.register(tool)
|
|
66
|
+
|
|
67
|
+
broken = tool_registry.list_broken_tools()
|
|
68
|
+
if broken:
|
|
69
|
+
logger.warning("Tools failed validation: %s", broken)
|
|
70
|
+
|
|
71
|
+
return tool_registry
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
TOOL_MODULES = [
|
|
75
|
+
SubsystemModule(
|
|
76
|
+
name="tool_registry",
|
|
77
|
+
factory=_create_tool_registry,
|
|
78
|
+
container_key="runtime.tool_registry",
|
|
79
|
+
dependencies=["runtime.execution_executor"],
|
|
80
|
+
)
|
|
81
|
+
]
|
|
@@ -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,122 @@
|
|
|
1
|
+
import ipaddress
|
|
2
|
+
import socket
|
|
3
|
+
from urllib.parse import urlparse
|
|
4
|
+
|
|
5
|
+
BLOCKED_HOSTS = frozenset(
|
|
6
|
+
{
|
|
7
|
+
"169.254.169.254", # AWS IMDS v1
|
|
8
|
+
"169.254.170.2", # AWS ECS metadata
|
|
9
|
+
"metadata.google.internal",
|
|
10
|
+
"metadata.goog", # GCP metadata alternate
|
|
11
|
+
"169.254.0.0", # link-local broadcast
|
|
12
|
+
"fd00:ec2::254", # AWS IPv6 IMDS
|
|
13
|
+
"100.100.100.200", # Alibaba Cloud metadata
|
|
14
|
+
}
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
BLOCKED_PREFIXES = (
|
|
18
|
+
"169.254.", # link-local range (catch-all)
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Explicit additional blocked networks not guaranteed by ip.is_private across all
|
|
22
|
+
# Python versions. ip.is_private was extended in 3.11; these guards ensure
|
|
23
|
+
# correctness on older runtimes too.
|
|
24
|
+
_BLOCKED_NETWORKS: list[ipaddress.IPv4Network | ipaddress.IPv6Network] = [
|
|
25
|
+
ipaddress.ip_network("100.64.0.0/10"), # Carrier-grade NAT (RFC 6598) / Alibaba Cloud
|
|
26
|
+
ipaddress.ip_network("fc00::/7"), # IPv6 ULA (includes fd00::/8 — AWS IPv6 metadata)
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _is_private_ip(address: str) -> tuple[bool, str]:
|
|
31
|
+
"""Returns (is_blocked, reason). Resolves hostnames.
|
|
32
|
+
|
|
33
|
+
CRITICAL: always resolves the hostname to an IP and checks the *resolved* IP
|
|
34
|
+
so that DNS-rebinding / CNAME tricks (external.attacker.com → 169.254.169.254)
|
|
35
|
+
are blocked. Uses socket.getaddrinfo() and checks ALL returned addresses.
|
|
36
|
+
"""
|
|
37
|
+
try:
|
|
38
|
+
ip = ipaddress.ip_address(address)
|
|
39
|
+
except ValueError:
|
|
40
|
+
# It's a hostname — resolve it via getaddrinfo (all returned addresses checked)
|
|
41
|
+
try:
|
|
42
|
+
socket.setdefaulttimeout(3)
|
|
43
|
+
resolved = socket.getaddrinfo(address, None, proto=socket.IPPROTO_TCP)
|
|
44
|
+
for _family, _type, _proto, _canonname, sockaddr in resolved:
|
|
45
|
+
ip_str = sockaddr[0]
|
|
46
|
+
blocked, reason = _is_private_ip(ip_str)
|
|
47
|
+
if blocked:
|
|
48
|
+
return True, f"hostname {address!r} resolves to blocked IP {ip_str}: {reason}"
|
|
49
|
+
except socket.gaierror:
|
|
50
|
+
return False, "" # Can't resolve — let the request fail naturally
|
|
51
|
+
return False, ""
|
|
52
|
+
|
|
53
|
+
if ip.is_loopback:
|
|
54
|
+
return True, "loopback address"
|
|
55
|
+
if ip.is_private:
|
|
56
|
+
return True, "private network range"
|
|
57
|
+
if ip.is_link_local:
|
|
58
|
+
return True, "link-local address (fe80::/10 or 169.254.x.x)"
|
|
59
|
+
if ip.is_reserved:
|
|
60
|
+
return True, "reserved address"
|
|
61
|
+
if ip.is_multicast:
|
|
62
|
+
return True, "multicast address"
|
|
63
|
+
# Explicitly check for 0.0.0.0/8 (unspecified)
|
|
64
|
+
if isinstance(ip, ipaddress.IPv4Address) and ip in ipaddress.ip_network("0.0.0.0/8"):
|
|
65
|
+
return True, "unspecified address range"
|
|
66
|
+
# Check additional blocked networks (carrier-grade NAT, IPv6 ULA)
|
|
67
|
+
for net in _BLOCKED_NETWORKS:
|
|
68
|
+
try:
|
|
69
|
+
if ip in net:
|
|
70
|
+
return True, f"blocked network range {net}"
|
|
71
|
+
except TypeError:
|
|
72
|
+
pass # IPv4 address vs IPv6 network (or vice-versa) — skip
|
|
73
|
+
return False, ""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def validate_url(url: str, allow_http: bool = False) -> tuple[bool, str | None]:
|
|
77
|
+
"""
|
|
78
|
+
Validate URL for SSRF safety.
|
|
79
|
+
Returns (is_valid, error_message).
|
|
80
|
+
"""
|
|
81
|
+
try:
|
|
82
|
+
parsed = urlparse(url)
|
|
83
|
+
except Exception:
|
|
84
|
+
return False, "Unparseable URL"
|
|
85
|
+
|
|
86
|
+
# Reject credentials in URL
|
|
87
|
+
if parsed.username or parsed.password:
|
|
88
|
+
return False, "URLs with embedded credentials are not allowed"
|
|
89
|
+
|
|
90
|
+
if parsed.scheme not in ("https", "http"):
|
|
91
|
+
return False, f"Scheme '{parsed.scheme}' not allowed"
|
|
92
|
+
if parsed.scheme == "http" and not allow_http:
|
|
93
|
+
return False, "HTTP not allowed — use HTTPS"
|
|
94
|
+
|
|
95
|
+
hostname = parsed.hostname
|
|
96
|
+
if not hostname:
|
|
97
|
+
return False, "No hostname"
|
|
98
|
+
hostname = hostname.lower().strip(".")
|
|
99
|
+
|
|
100
|
+
if hostname in BLOCKED_HOSTS:
|
|
101
|
+
return False, f"Host '{hostname}' is explicitly blocked"
|
|
102
|
+
for prefix in BLOCKED_PREFIXES:
|
|
103
|
+
if hostname.startswith(prefix):
|
|
104
|
+
return False, f"Host '{hostname}' matches blocked prefix"
|
|
105
|
+
|
|
106
|
+
# Reject numeric escape forms (0x7f000001, 0177.0.0.1, decimal integer, etc.)
|
|
107
|
+
# We use socket.inet_aton which handles hex, octal, decimal, integer, and multi-dot notations
|
|
108
|
+
# exactly the way the OS/socket libraries parse numeric IPv4 addresses.
|
|
109
|
+
try:
|
|
110
|
+
packed = socket.inet_aton(hostname)
|
|
111
|
+
ip_str = socket.inet_ntoa(packed)
|
|
112
|
+
blocked, reason = _is_private_ip(ip_str)
|
|
113
|
+
if blocked:
|
|
114
|
+
return False, f"Numeric IP {hostname} resolves to blocked: {reason}"
|
|
115
|
+
except OSError:
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
blocked, reason = _is_private_ip(hostname)
|
|
119
|
+
if blocked:
|
|
120
|
+
return False, reason
|
|
121
|
+
|
|
122
|
+
return True, None
|