eljay-ai 1.1.0
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.
- package/AGENTS.md +57 -0
- package/README.md +479 -0
- package/agent/__init__.py +10 -0
- package/agent/agent.py +269 -0
- package/agent/agents/__init__.py +34 -0
- package/agent/agents/registry.py +212 -0
- package/agent/builtin_tools.py +591 -0
- package/agent/chat.py +354 -0
- package/agent/config.py +67 -0
- package/agent/context.py +63 -0
- package/agent/edits.py +167 -0
- package/agent/gitaware.py +126 -0
- package/agent/hardware.py +331 -0
- package/agent/knowledge.py +133 -0
- package/agent/memory.py +98 -0
- package/agent/ollama_client.py +133 -0
- package/agent/permissions.py +93 -0
- package/agent/providers/__init__.py +51 -0
- package/agent/providers/base.py +78 -0
- package/agent/providers/image.py +110 -0
- package/agent/providers/ollama.py +111 -0
- package/agent/providers/video.py +96 -0
- package/agent/providers/web.py +155 -0
- package/agent/router.py +200 -0
- package/agent/rules.py +52 -0
- package/agent/runner.py +168 -0
- package/agent/skills.py +161 -0
- package/agent/tools.py +102 -0
- package/agent/verification.py +80 -0
- package/agent/workspace.py +487 -0
- package/bin/eljay +5 -0
- package/bin/eljay.cmd +4 -0
- package/bin/myagent +4 -0
- package/bin/myagent.cmd +4 -0
- package/eljay-ai-1.1.0.tgz +0 -0
- package/eljay.js +56 -0
- package/eljay.py +150 -0
- package/install.ps1 +28 -0
- package/knowledge/reference/diffusers.md +32 -0
- package/knowledge/reference/video-providers.md +21 -0
- package/knowledge/setup/comfyui.md +35 -0
- package/knowledge/setup/image-providers.md +16 -0
- package/knowledge/test-category/test-entry.md +5 -0
- package/myagent.py +30 -0
- package/package.json +40 -0
- package/skills/coding/code-review.md +3 -0
- package/skills/coding/fix-attempt-protocol.md +9 -0
- package/skills/debugging/root-cause-analysis.md +9 -0
- package/skills/general/communication.md +7 -0
- package/skills/laravel/authentication.md +15 -0
- package/skills/mysql/performance.md +9 -0
- package/skills/php/standards.md +8 -0
- package/skills/react/component-best-practices.md +8 -0
- package/skills/research/source-tracking.md +7 -0
- package/skills/security/input-validation.md +9 -0
- package/skills/testing/pytest-best-practices.md +7 -0
- package/tests/run_all.py +34 -0
- package/tests/test_agent_core.py +385 -0
- package/tests/test_capabilities.py +169 -0
- package/tests/test_edits.py +117 -0
- package/tests/test_eljay.py +209 -0
- package/tests/test_runner.py +114 -0
- package/tests/test_universal.py +458 -0
- package/tests/test_workspace.py +221 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Web search provider — free, no-API-key research via DuckDuckGo Lite.
|
|
2
|
+
|
|
3
|
+
Uses ``urllib.request`` (stdlib) and a tiny ``html.parser`` to extract
|
|
4
|
+
search-result titles, URLs, and snippets from the DDG Lite endpoint.
|
|
5
|
+
No API key required, respects robots.txt, minimal requests.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import html as html_lib
|
|
11
|
+
import re
|
|
12
|
+
import urllib.parse
|
|
13
|
+
import urllib.request
|
|
14
|
+
from html.parser import HTMLParser
|
|
15
|
+
|
|
16
|
+
from .base import Provider
|
|
17
|
+
|
|
18
|
+
DDG_LITE = "https://lite.duckduckgo.com/lite/"
|
|
19
|
+
USER_AGENT = "ElJay-AI/1.0 (local research agent; stdlib only)"
|
|
20
|
+
MAX_RESULTS = 10
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _DDGResultParser(HTMLParser):
|
|
24
|
+
"""Extract result anchors + snippets from the DDG Lite HTML table."""
|
|
25
|
+
|
|
26
|
+
def __init__(self):
|
|
27
|
+
super().__init__()
|
|
28
|
+
self.results: list[tuple[str, str, str]] = [] # (title, url, snippet)
|
|
29
|
+
self._in_link = False
|
|
30
|
+
self._in_snippet = False
|
|
31
|
+
self._current_title = ""
|
|
32
|
+
self._current_href = ""
|
|
33
|
+
self._snippet_parts: list[str] = []
|
|
34
|
+
|
|
35
|
+
def handle_starttag(self, tag, attrs):
|
|
36
|
+
attrs_d = dict(attrs)
|
|
37
|
+
if tag == "a" and attrs_d.get("class") == "result-link":
|
|
38
|
+
self._in_link = True
|
|
39
|
+
self._current_href = attrs_d.get("href", "")
|
|
40
|
+
elif tag == "td" and attrs_d.get("class") == "result-snippet":
|
|
41
|
+
self._in_snippet = True
|
|
42
|
+
|
|
43
|
+
def handle_endtag(self, tag):
|
|
44
|
+
if tag == "a" and self._in_link:
|
|
45
|
+
self._in_link = False
|
|
46
|
+
if self._current_title and self._extract_url(self._current_href):
|
|
47
|
+
self.results.append(
|
|
48
|
+
(
|
|
49
|
+
html_lib.unescape(self._current_title).strip(),
|
|
50
|
+
self._extract_url(self._current_href),
|
|
51
|
+
"",
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
elif tag == "td" and self._in_snippet:
|
|
55
|
+
self._in_snippet = False
|
|
56
|
+
snippet = " ".join(self._snippet_parts).strip()
|
|
57
|
+
if self.results and not self.results[-1][2]:
|
|
58
|
+
title, url, _ = self.results[-1]
|
|
59
|
+
self.results[-1] = (title, url, snippet)
|
|
60
|
+
self._current_title = ""
|
|
61
|
+
self._snippet_parts = []
|
|
62
|
+
|
|
63
|
+
def handle_data(self, data):
|
|
64
|
+
if self._in_link:
|
|
65
|
+
self._current_title += data
|
|
66
|
+
elif self._in_snippet:
|
|
67
|
+
self._snippet_parts.append(data)
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def _extract_url(href: str) -> str:
|
|
71
|
+
"""Extract the real destination from a DDG redirect URL."""
|
|
72
|
+
parsed = urllib.parse.urlparse(href)
|
|
73
|
+
params = urllib.parse.parse_qs(parsed.query)
|
|
74
|
+
uddg = params.get("uddg", [""])[0]
|
|
75
|
+
if uddg:
|
|
76
|
+
return html_lib.unescape(uddg)
|
|
77
|
+
return href
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def search_ddg(query: str, max_results: int = MAX_RESULTS) -> list[dict]:
|
|
81
|
+
"""Search DuckDuckGo Lite and return structured results."""
|
|
82
|
+
url = DDG_LITE + "?" + urllib.parse.urlencode({"q": query, "kl": "us-en"})
|
|
83
|
+
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
84
|
+
try:
|
|
85
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
86
|
+
html_text = resp.read().decode("utf-8", errors="replace")
|
|
87
|
+
except Exception as exc:
|
|
88
|
+
return [{"error": f"Search request failed: {exc}"}]
|
|
89
|
+
|
|
90
|
+
parser = _DDGResultParser()
|
|
91
|
+
parser.feed(html_text)
|
|
92
|
+
|
|
93
|
+
out = []
|
|
94
|
+
for title, url, snippet in parser.results[:max_results]:
|
|
95
|
+
out.append(
|
|
96
|
+
{"title": title or "(no title)", "url": url, "snippet": snippet}
|
|
97
|
+
)
|
|
98
|
+
if not out:
|
|
99
|
+
out.append({"error": "No results returned for this query."})
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def fetch_web_page(url: str, max_chars: int = 8000) -> str:
|
|
104
|
+
"""Fetch *url* and return readable text (tags stripped).
|
|
105
|
+
|
|
106
|
+
Honest error messages on failure; never raises to the caller.
|
|
107
|
+
"""
|
|
108
|
+
if not url.startswith(("http://", "https://")):
|
|
109
|
+
return f"ERROR: not a valid URL: {url}"
|
|
110
|
+
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
111
|
+
try:
|
|
112
|
+
with urllib.request.urlopen(req, timeout=20) as resp:
|
|
113
|
+
raw = resp.read()
|
|
114
|
+
except Exception as exc:
|
|
115
|
+
return f"ERROR: could not fetch {url}: {exc}"
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
text = raw.decode("utf-8", errors="replace")
|
|
119
|
+
except Exception:
|
|
120
|
+
text = raw.decode("latin-1", errors="replace")
|
|
121
|
+
|
|
122
|
+
# Strip scripts and styles.
|
|
123
|
+
text = re.sub( r"<(script|style)[^>]*>.*?</\1>", "", text, flags=re.DOTALL | re.IGNORECASE)
|
|
124
|
+
# Strip remaining HTML tags.
|
|
125
|
+
text = re.sub(r"<[^>]+>", " ", text)
|
|
126
|
+
# Collapse whitespace.
|
|
127
|
+
text = re.sub(r"\s+", " ", text).strip()
|
|
128
|
+
if len(text) > max_chars:
|
|
129
|
+
text = text[:max_chars] + " ... [truncated]"
|
|
130
|
+
return text
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class WebSearchProvider(Provider):
|
|
134
|
+
"""Free, no-API-key web search via DuckDuckGo Lite."""
|
|
135
|
+
|
|
136
|
+
def __init__(self):
|
|
137
|
+
super().__init__(
|
|
138
|
+
name="duckduckgo",
|
|
139
|
+
label="DuckDuckGo (free web)",
|
|
140
|
+
capabilities=["web-search", "web-fetch"],
|
|
141
|
+
)
|
|
142
|
+
self.query: str | None = None
|
|
143
|
+
|
|
144
|
+
def detect(self) -> bool:
|
|
145
|
+
"""DDG Lite is always available in principle; we test reachability."""
|
|
146
|
+
try:
|
|
147
|
+
results = search_ddg("test connectivity", max_results=1)
|
|
148
|
+
if results and "error" not in results[0]:
|
|
149
|
+
self.available = True
|
|
150
|
+
return True
|
|
151
|
+
self.error = "Could not reach DuckDuckGo Lite."
|
|
152
|
+
return False
|
|
153
|
+
except Exception as exc:
|
|
154
|
+
self.error = str(exc)
|
|
155
|
+
return False
|
package/agent/router.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Agent routing and orchestration for ElJay AI.
|
|
2
|
+
|
|
3
|
+
The ``AgentRouter`` bridges the user's natural-language request and the
|
|
4
|
+
specialized agent definitions in ``agent/agents/registry.py``. It decides:
|
|
5
|
+
|
|
6
|
+
* Which agent persona to use (coding, research, image, video, knowledge, or
|
|
7
|
+
the main coordinator).
|
|
8
|
+
* Which system-prompt additions to inject.
|
|
9
|
+
* Which tools to make available.
|
|
10
|
+
* Which skills and knowledge entries are relevant.
|
|
11
|
+
|
|
12
|
+
This is a lightweight, deterministic layer — routing uses keyword matching
|
|
13
|
+
(no LLM call) so it is fast and predictable.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
from .agents import AgentDefinition, get_agent, list_agents, route_request
|
|
22
|
+
from .providers import PROVIDERS, get_provider_capable_of
|
|
23
|
+
from .skills import Skills
|
|
24
|
+
from .knowledge import KnowledgeBase
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# Capability → provider-name map (matches Provider names in PROVIDERS).
|
|
28
|
+
_PROVIDER_NAMES = {
|
|
29
|
+
"text": "ollama",
|
|
30
|
+
"coding": "ollama",
|
|
31
|
+
"web-search": "duckduckgo",
|
|
32
|
+
"web-fetch": "duckduckgo",
|
|
33
|
+
"image-generation": "image-local",
|
|
34
|
+
"video-generation": "video-local",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AgentRouter:
|
|
39
|
+
"""Route user queries to the right agent persona.
|
|
40
|
+
|
|
41
|
+
Parameters
|
|
42
|
+
----------
|
|
43
|
+
core
|
|
44
|
+
The :class:`AgentCore` instance (optional, for future multi-agent
|
|
45
|
+
delegation).
|
|
46
|
+
workspace
|
|
47
|
+
The :class:`Workspace` (optional, used for project-local context).
|
|
48
|
+
skills
|
|
49
|
+
The :class:`Skills` instance (may be empty if no ``skills/`` dir).
|
|
50
|
+
knowledge
|
|
51
|
+
The :class:`KnowledgeBase` instance (may be empty).
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
core: object | None = None,
|
|
57
|
+
skills: Skills | None = None,
|
|
58
|
+
knowledge: KnowledgeBase | None = None,
|
|
59
|
+
workspace: object | None = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
self.core = core
|
|
62
|
+
self.workspace = workspace
|
|
63
|
+
self.skills = skills or _EmptySkills()
|
|
64
|
+
self.knowledge = knowledge or _EmptyKnowledge()
|
|
65
|
+
self.registry = list_agents()
|
|
66
|
+
self._providers_checked = False
|
|
67
|
+
self._provider_status: dict[str, bool] = {}
|
|
68
|
+
|
|
69
|
+
# ------------------------------------------------------------------
|
|
70
|
+
# Provider status
|
|
71
|
+
# ------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def provider_status(self) -> dict[str, bool]:
|
|
74
|
+
"""Return ``{provider_name: is_available}`` for all providers."""
|
|
75
|
+
if not self._providers_checked:
|
|
76
|
+
self._providers_checked = True
|
|
77
|
+
for p in PROVIDERS:
|
|
78
|
+
key = p.name
|
|
79
|
+
try:
|
|
80
|
+
self._provider_status[key] = p.is_available()
|
|
81
|
+
except Exception:
|
|
82
|
+
self._provider_status[key] = False
|
|
83
|
+
return self._provider_status
|
|
84
|
+
|
|
85
|
+
# ------------------------------------------------------------------
|
|
86
|
+
# Routing
|
|
87
|
+
# ------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
def route(self, query: str) -> AgentDefinition:
|
|
90
|
+
"""Return the agent definition best suited to *query*."""
|
|
91
|
+
providers = self.provider_status()
|
|
92
|
+
# Translate capability names to boolean availability.
|
|
93
|
+
available = {}
|
|
94
|
+
for p in PROVIDERS:
|
|
95
|
+
available[p.name] = providers.get(p.name, False)
|
|
96
|
+
return route_request(query, available)
|
|
97
|
+
|
|
98
|
+
def __len__(self) -> int:
|
|
99
|
+
"""Return the number of registered agents."""
|
|
100
|
+
return len(self.registry)
|
|
101
|
+
|
|
102
|
+
def agent_by_name(self, name: str) -> Optional[AgentDefinition]:
|
|
103
|
+
"""Look up an agent by name (case-insensitive)."""
|
|
104
|
+
return get_agent(name.lower())
|
|
105
|
+
|
|
106
|
+
def all_agents(self) -> list[AgentDefinition]:
|
|
107
|
+
"""Return all agent definitions, most specific first."""
|
|
108
|
+
return list_agents()
|
|
109
|
+
|
|
110
|
+
# ------------------------------------------------------------------
|
|
111
|
+
# System prompt augmentation
|
|
112
|
+
# ------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
def system_additions(self, agent: AgentDefinition) -> str:
|
|
115
|
+
"""Return the system-prompt additions for *agent*."""
|
|
116
|
+
return agent.system_additions
|
|
117
|
+
|
|
118
|
+
def available_tools(self, agent: AgentDefinition) -> list[str]:
|
|
119
|
+
"""Return the list of tool names the agent may use.
|
|
120
|
+
|
|
121
|
+
The ``main`` coordinator gets all tools; specialised agents get only
|
|
122
|
+
the tools listed in their definition.
|
|
123
|
+
"""
|
|
124
|
+
if agent.name == "main":
|
|
125
|
+
return [] # empty list = "all tools" (convention)
|
|
126
|
+
return list(agent.tools)
|
|
127
|
+
|
|
128
|
+
# ------------------------------------------------------------------
|
|
129
|
+
# Skills & Knowledge injection
|
|
130
|
+
# ------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
def relevant_skills(self, query: str, limit: int = 3) -> list[str]:
|
|
133
|
+
"""Return relevant skill content snippets for *query*."""
|
|
134
|
+
skills = self.skills.relevant_to(query)
|
|
135
|
+
return [s.content for s in skills[:limit]]
|
|
136
|
+
|
|
137
|
+
def relevant_knowledge(self, query: str, limit: int = 3) -> list[str]:
|
|
138
|
+
"""Return relevant knowledge entries for *query*."""
|
|
139
|
+
entries = self.knowledge.search(query)
|
|
140
|
+
return [e.content for e in entries[:limit]]
|
|
141
|
+
|
|
142
|
+
def inject_context(self, query: str, agent: AgentDefinition) -> str:
|
|
143
|
+
"""Build a context string to append to the system prompt.
|
|
144
|
+
|
|
145
|
+
Includes relevant skills and knowledge entries for the agent + query.
|
|
146
|
+
"""
|
|
147
|
+
parts: list[str] = []
|
|
148
|
+
skills = self.relevant_skills(query)
|
|
149
|
+
if skills:
|
|
150
|
+
parts.append("## Relevant Skills\n\n" + "\n\n---\n\n".join(skills))
|
|
151
|
+
knowledge = self.relevant_knowledge(query)
|
|
152
|
+
if knowledge:
|
|
153
|
+
parts.append("## Relevant Knowledge\n\n" + "\n\n---\n\n".join(knowledge))
|
|
154
|
+
return "\n\n".join(parts)
|
|
155
|
+
|
|
156
|
+
# ------------------------------------------------------------------
|
|
157
|
+
# Orchestration (future: multi-agent delegation)
|
|
158
|
+
# ------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
def needs_delegation(self, query: str) -> bool:
|
|
161
|
+
"""Return True if this query is better handled by a specialist agent.
|
|
162
|
+
|
|
163
|
+
The Main coordinator delegates when the matched agent is not ``main``.
|
|
164
|
+
"""
|
|
165
|
+
agent = self.route(query)
|
|
166
|
+
return agent.name != "main"
|
|
167
|
+
|
|
168
|
+
def delegated_agent_for(self, query: str) -> Optional[AgentDefinition]:
|
|
169
|
+
"""Return the specialist agent for *query*, or None if Main is best."""
|
|
170
|
+
agent = self.route(query)
|
|
171
|
+
if agent.name == "main":
|
|
172
|
+
return None
|
|
173
|
+
return agent
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
# Lightweight empty fallbacks so the router works even when no skills/
|
|
178
|
+
# knowledge directories exist yet.
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
class _EmptySkills:
|
|
182
|
+
def relevant_to(self, query: str) -> list:
|
|
183
|
+
return []
|
|
184
|
+
|
|
185
|
+
def all_skills(self) -> list:
|
|
186
|
+
return []
|
|
187
|
+
|
|
188
|
+
def summary(self) -> str:
|
|
189
|
+
return "no skills"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class _EmptyKnowledge:
|
|
193
|
+
def search(self, query: str) -> list:
|
|
194
|
+
return []
|
|
195
|
+
|
|
196
|
+
def entries(self) -> list:
|
|
197
|
+
return []
|
|
198
|
+
|
|
199
|
+
def summary(self) -> str:
|
|
200
|
+
return "empty"
|
package/agent/rules.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Project rules: load AGENTS.md and turn it into system instructions.
|
|
2
|
+
|
|
3
|
+
The agent must read the project's rules before making changes, so the rules are
|
|
4
|
+
appended to the system prompt at startup.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .workspace import Workspace
|
|
10
|
+
|
|
11
|
+
RULES_FILENAMES = ("AGENTS.md", "agents.md", ".agent/AGENTS.md")
|
|
12
|
+
MAX_RULES_CHARS = 8000
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def find_rules_file(ws: Workspace) -> str | None:
|
|
16
|
+
for name in RULES_FILENAMES:
|
|
17
|
+
try:
|
|
18
|
+
path = ws.resolve(name)
|
|
19
|
+
except ValueError:
|
|
20
|
+
continue
|
|
21
|
+
if path.is_file():
|
|
22
|
+
return name
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_rules(ws: Workspace) -> tuple[str, str | None]:
|
|
27
|
+
"""Return (rules_text, filename). rules_text is "" when there are none."""
|
|
28
|
+
name = find_rules_file(ws)
|
|
29
|
+
if name is None:
|
|
30
|
+
return "", None
|
|
31
|
+
try:
|
|
32
|
+
path = ws.resolve(name)
|
|
33
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
34
|
+
except (OSError, ValueError):
|
|
35
|
+
return "", None
|
|
36
|
+
|
|
37
|
+
if len(text) > MAX_RULES_CHARS:
|
|
38
|
+
text = text[:MAX_RULES_CHARS] + "\n... (rules truncated)"
|
|
39
|
+
return text.strip(), name
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build_system_prompt(base_prompt: str, rules_text: str, source: str | None) -> str:
|
|
43
|
+
if not rules_text:
|
|
44
|
+
return base_prompt
|
|
45
|
+
return (
|
|
46
|
+
f"{base_prompt}\n\n"
|
|
47
|
+
f"PROJECT RULES (from {source}) — these are binding. Follow them, and ask "
|
|
48
|
+
"before doing anything they forbid:\n"
|
|
49
|
+
"-----\n"
|
|
50
|
+
f"{rules_text}\n"
|
|
51
|
+
"-----"
|
|
52
|
+
)
|
package/agent/runner.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Terminal command execution — deliberately narrow.
|
|
2
|
+
|
|
3
|
+
Safety model:
|
|
4
|
+
- Commands are NEVER auto-run (never `safe`). Every command needs approval.
|
|
5
|
+
- A command whose program is on the allow-list is `ask`; anything else is `high`.
|
|
6
|
+
- Known-dangerous patterns force `high` regardless of the program.
|
|
7
|
+
- Shell operators (|, &, ;, <, >, backticks, $(...)) are refused: we run a single
|
|
8
|
+
program without a shell, so there is no shell injection surface.
|
|
9
|
+
- The working directory is confined to the project root.
|
|
10
|
+
- Output is captured, and the run is bounded by a timeout.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import shlex
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
|
|
21
|
+
from .tools import RISK_ASK, RISK_HIGH
|
|
22
|
+
from .workspace import Workspace
|
|
23
|
+
|
|
24
|
+
MAX_OUTPUT_CHARS = 20_000
|
|
25
|
+
DEFAULT_TIMEOUT = 120
|
|
26
|
+
MAX_TIMEOUT = 600
|
|
27
|
+
|
|
28
|
+
# Programs that may run with a normal confirmation.
|
|
29
|
+
ALLOWED_PROGRAMS = frozenset(
|
|
30
|
+
{
|
|
31
|
+
# python
|
|
32
|
+
"python", "python3", "py", "pip", "pip3", "pytest", "mypy", "ruff", "black",
|
|
33
|
+
# javascript / typescript
|
|
34
|
+
"node", "npm", "npx", "yarn", "pnpm", "bun", "tsc", "eslint", "vite", "jest",
|
|
35
|
+
# php
|
|
36
|
+
"php", "composer", "artisan",
|
|
37
|
+
# git (dangerous subcommands are caught below)
|
|
38
|
+
"git",
|
|
39
|
+
# other languages / build tools
|
|
40
|
+
"go", "cargo", "rustc", "dotnet", "java", "mvn", "gradle", "make",
|
|
41
|
+
"ruby", "bundle", "gem",
|
|
42
|
+
# harmless inspection
|
|
43
|
+
"echo", "ls", "dir", "cat", "type", "pwd", "where", "which", "whoami",
|
|
44
|
+
"node", "tree",
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Patterns that ALWAYS mean high risk, whatever the program.
|
|
49
|
+
_HIGH_RISK_PATTERNS = tuple(
|
|
50
|
+
re.compile(p)
|
|
51
|
+
for p in (
|
|
52
|
+
r"\brm\b", r"\brmdir\b", r"\brd\b", r"\bdel\b", r"\berase\b",
|
|
53
|
+
r"\bformat\b", r"\bfdisk\b", r"\bmkfs\b",
|
|
54
|
+
r"\bshutdown\b", r"\breboot\b", r"\btaskkill\b", r"\bkill\b", r"\bsudo\b",
|
|
55
|
+
r"git\s+push", r"git\s+reset", r"git\s+clean", r"git\s+rebase",
|
|
56
|
+
r"git\s+checkout\s+--", r"git\s+branch\s+-d", r"git\s+filter-branch",
|
|
57
|
+
r"npm\s+uninstall", r"npm\s+install\s+(-g|--global)",
|
|
58
|
+
r"yarn\s+remove", r"pnpm\s+remove", r"composer\s+remove",
|
|
59
|
+
r"pip\s+uninstall", r"pip\s+install\s+--user",
|
|
60
|
+
r"drop\s+(database|table)", r"truncate\s+table", r"delete\s+from",
|
|
61
|
+
r"\|\s*(ba|z|)sh\b", r"\bcurl\b[^|]*\|", r"\bwget\b[^|]*\|",
|
|
62
|
+
r">\s*/dev/sd", r"\bchmod\s+777\b",
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Shell operators we do not support (we never use a shell).
|
|
67
|
+
_SHELL_META = re.compile(r"[|&;<>]|\$\(|`")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def analyse(command: str) -> tuple[list[str] | None, str]:
|
|
71
|
+
"""Parse a command into argv. Returns (argv, "") or (None, error_message)."""
|
|
72
|
+
text = (command or "").strip()
|
|
73
|
+
if not text:
|
|
74
|
+
return None, "ERROR: command is required"
|
|
75
|
+
if _SHELL_META.search(text):
|
|
76
|
+
return None, (
|
|
77
|
+
"ERROR: shell operators (|, &, ;, <, >, backticks, $(...)) are not "
|
|
78
|
+
"allowed. Run a single command with no pipes or redirection."
|
|
79
|
+
)
|
|
80
|
+
try:
|
|
81
|
+
parts = shlex.split(text, posix=True)
|
|
82
|
+
except ValueError as exc:
|
|
83
|
+
return None, f"ERROR: could not parse the command: {exc}"
|
|
84
|
+
if not parts:
|
|
85
|
+
return None, "ERROR: command is required"
|
|
86
|
+
return parts, ""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _program_name(token: str) -> str:
|
|
90
|
+
name = os.path.basename(token).lower()
|
|
91
|
+
for ext in (".exe", ".cmd", ".bat", ".com"):
|
|
92
|
+
if name.endswith(ext):
|
|
93
|
+
name = name[: -len(ext)]
|
|
94
|
+
return name
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def classify_command(command: str) -> str:
|
|
98
|
+
"""Return RISK_ASK for allow-listed programs, RISK_HIGH otherwise."""
|
|
99
|
+
lowered = (command or "").lower()
|
|
100
|
+
for pattern in _HIGH_RISK_PATTERNS:
|
|
101
|
+
if pattern.search(lowered):
|
|
102
|
+
return RISK_HIGH
|
|
103
|
+
argv, _err = analyse(command)
|
|
104
|
+
if not argv:
|
|
105
|
+
return RISK_HIGH
|
|
106
|
+
return RISK_ASK if _program_name(argv[0]) in ALLOWED_PROGRAMS else RISK_HIGH
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def run_command(
|
|
110
|
+
ws: Workspace,
|
|
111
|
+
command: str,
|
|
112
|
+
cwd: str = ".",
|
|
113
|
+
timeout: int = DEFAULT_TIMEOUT,
|
|
114
|
+
) -> str:
|
|
115
|
+
"""Run one command in the project root and return a readable report."""
|
|
116
|
+
argv, error = analyse(command)
|
|
117
|
+
if argv is None:
|
|
118
|
+
return error
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
workdir = ws.resolve(cwd or ".")
|
|
122
|
+
except ValueError as exc:
|
|
123
|
+
return f"ERROR: {exc}"
|
|
124
|
+
if not workdir.is_dir():
|
|
125
|
+
return f"ERROR: working directory does not exist: {cwd}"
|
|
126
|
+
|
|
127
|
+
program = shutil.which(argv[0])
|
|
128
|
+
if program is None:
|
|
129
|
+
return f"ERROR: command not found: {argv[0]}"
|
|
130
|
+
argv[0] = program
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
timeout = max(1, min(int(timeout), MAX_TIMEOUT))
|
|
134
|
+
except (TypeError, ValueError):
|
|
135
|
+
timeout = DEFAULT_TIMEOUT
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
proc = subprocess.run(
|
|
139
|
+
argv,
|
|
140
|
+
cwd=str(workdir),
|
|
141
|
+
capture_output=True,
|
|
142
|
+
text=True,
|
|
143
|
+
encoding="utf-8",
|
|
144
|
+
errors="replace",
|
|
145
|
+
timeout=timeout,
|
|
146
|
+
)
|
|
147
|
+
except subprocess.TimeoutExpired:
|
|
148
|
+
return (
|
|
149
|
+
f"Command: {command}\nResult: FAILED\n"
|
|
150
|
+
f"Reason: timed out after {timeout}s and was stopped."
|
|
151
|
+
)
|
|
152
|
+
except (OSError, ValueError) as exc:
|
|
153
|
+
return f"ERROR: could not run '{command}': {exc}"
|
|
154
|
+
|
|
155
|
+
output = proc.stdout or ""
|
|
156
|
+
if proc.stderr:
|
|
157
|
+
output += ("\n" if output else "") + "[stderr]\n" + proc.stderr
|
|
158
|
+
output = output.strip()
|
|
159
|
+
if len(output) > MAX_OUTPUT_CHARS:
|
|
160
|
+
output = output[:MAX_OUTPUT_CHARS] + "\n... (output truncated)"
|
|
161
|
+
|
|
162
|
+
status = "succeeded" if proc.returncode == 0 else "FAILED"
|
|
163
|
+
report = f"Command: {command}\nExit code: {proc.returncode} ({status})"
|
|
164
|
+
if output:
|
|
165
|
+
report += f"\n--- output ---\n{output}"
|
|
166
|
+
else:
|
|
167
|
+
report += "\n(no output)"
|
|
168
|
+
return report
|