opencommand 0.1.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.
- opencommand/__init__.py +4 -0
- opencommand/cli.py +375 -0
- opencommand/core/agent.py +91 -0
- opencommand/core/config.py +65 -0
- opencommand/core/errors.py +23 -0
- opencommand/core/logging.py +65 -0
- opencommand/core/supervisor.py +81 -0
- opencommand/core/tools.py +136 -0
- opencommand/engine/__init__.py +66 -0
- opencommand/engine/runner.py +275 -0
- opencommand/modules/knowledge.py +85 -0
- opencommand/modules/memory/MEMORY.md +14 -0
- opencommand/modules/skills/bash.md +14 -0
- opencommand/modules/skills/powershell.md +15 -0
- opencommand/modules/skills/python.md +20 -0
- opencommand/modules/skills/system.md +38 -0
- opencommand/modules/system/automatic.py +151 -0
- opencommand/modules/system/cron.py +193 -0
- opencommand/modules/system/notify.py +85 -0
- opencommand/modules/system/platform.py +197 -0
- opencommand/modules/templates.py +227 -0
- opencommand/modules/tools/desktop.py +127 -0
- opencommand/modules/tools/files.py +82 -0
- opencommand/modules/tools/instructions.py +163 -0
- opencommand/modules/tools/memory.py +78 -0
- opencommand/modules/tools/playwright.py +61 -0
- opencommand/modules/tools/research.py +46 -0
- opencommand/modules/tools/system.py +163 -0
- opencommand/modules/tools/terminal.py +53 -0
- opencommand/providers/provider.py +157 -0
- opencommand/swarm/agents/commander.py +530 -0
- opencommand/swarm/agents/worker.py +26 -0
- opencommand/tui/dashboard.py +41 -0
- opencommand-0.1.0.dist-info/METADATA +76 -0
- opencommand-0.1.0.dist-info/RECORD +37 -0
- opencommand-0.1.0.dist-info/WHEEL +4 -0
- opencommand-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Instructions tool: the swarm's self-improvement surface.
|
|
2
|
+
|
|
3
|
+
Lets the Commander (and workers) create, update, and list the project's
|
|
4
|
+
*instruction* artifacts that shape future runs:
|
|
5
|
+
|
|
6
|
+
- skills -> ``modules/skills/*.md`` (how to do a kind of task)
|
|
7
|
+
- tasks -> ``modules/tasks/*.md`` (reusable task/playbook templates)
|
|
8
|
+
- memory -> ``<workspace>/.opencommand/memory/*.md`` (shared long-term notes)
|
|
9
|
+
- docs -> ``<workspace>/.opencommand/docs/*.md`` (learned reference docs)
|
|
10
|
+
|
|
11
|
+
The Commander calls this at the end of a run to distill what worked (new skills,
|
|
12
|
+
corrected instructions, gotchas) so the next run starts smarter. Workers read
|
|
13
|
+
these via the knowledge base that is injected into their system prompt.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from ...core.tools import BaseTool
|
|
22
|
+
from ..templates import validate_markdown
|
|
23
|
+
|
|
24
|
+
# Built-in skills ship with the package (read-only defaults). The swarm writes
|
|
25
|
+
# NEW/updated skills under the workspace so an installed (read-only) package is
|
|
26
|
+
# never mutated; tasks/docs/memory already live under the workspace.
|
|
27
|
+
_PKG_ROOT = Path(__file__).resolve().parents[2]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _now() -> str:
|
|
31
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def builtin_skills_dir() -> Path:
|
|
35
|
+
return _PKG_ROOT / "modules" / "skills"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def skills_dir(workspace: Path) -> Path:
|
|
39
|
+
d = workspace / ".opencommand" / "skills"
|
|
40
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
return d
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def tasks_dir(workspace: Path) -> Path:
|
|
45
|
+
d = workspace / ".opencommand" / "tasks"
|
|
46
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
return d
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def docs_dir(workspace: Path) -> Path:
|
|
51
|
+
d = workspace / ".opencommand" / "docs"
|
|
52
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
return d
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _memory_dir(workspace: Path) -> Path:
|
|
57
|
+
from .memory import memory_dir
|
|
58
|
+
|
|
59
|
+
return memory_dir(workspace)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class InstructionsTool(BaseTool):
|
|
63
|
+
name = "instructions"
|
|
64
|
+
description = (
|
|
65
|
+
"Self-improvement surface: create/update/list the swarm's instruction "
|
|
66
|
+
"artifacts (skills, tasks, docs, memory) so future runs start smarter. "
|
|
67
|
+
"Use after a goal to distill what worked."
|
|
68
|
+
)
|
|
69
|
+
parameters = {
|
|
70
|
+
"type": "object",
|
|
71
|
+
"properties": {
|
|
72
|
+
"action": {"type": "string",
|
|
73
|
+
"enum": ["save", "update", "list", "read"]},
|
|
74
|
+
"kind": {"type": "string",
|
|
75
|
+
"enum": ["skill", "task", "doc", "memory"],
|
|
76
|
+
"description": "Which instruction store to target."},
|
|
77
|
+
"name": {"type": "string",
|
|
78
|
+
"description": "File name without extension (for save/update/read)."},
|
|
79
|
+
"body": {"type": "string",
|
|
80
|
+
"description": "Markdown content (for save/update)."},
|
|
81
|
+
"tags": {"type": "array", "items": {"type": "string"},
|
|
82
|
+
"description": "Tags for memory/docs (optional)."},
|
|
83
|
+
"query": {"type": "string",
|
|
84
|
+
"description": "Name or topic to read/list (optional)."},
|
|
85
|
+
},
|
|
86
|
+
"required": ["action", "kind"],
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
def __init__(self, workspace: Path) -> None:
|
|
90
|
+
self.workspace = workspace
|
|
91
|
+
|
|
92
|
+
def _root(self, kind: str) -> Path:
|
|
93
|
+
if kind == "skill":
|
|
94
|
+
return skills_dir(self.workspace)
|
|
95
|
+
if kind == "task":
|
|
96
|
+
return tasks_dir(self.workspace)
|
|
97
|
+
if kind == "doc":
|
|
98
|
+
return docs_dir(self.workspace)
|
|
99
|
+
if kind == "memory":
|
|
100
|
+
return _memory_dir(self.workspace)
|
|
101
|
+
raise ValueError(f"unknown kind: {kind}")
|
|
102
|
+
|
|
103
|
+
def run(self, action: str, kind: str, name: str | None = None,
|
|
104
|
+
body: str | None = None, tags: list[str] | None = None,
|
|
105
|
+
query: str | None = None) -> str:
|
|
106
|
+
root = self._root(kind)
|
|
107
|
+
if action == "list":
|
|
108
|
+
items = sorted(p.name for p in root.glob("*.md"))
|
|
109
|
+
return "\n".join(items) if items else f"(no {kind} files)"
|
|
110
|
+
if action == "read":
|
|
111
|
+
if not name and not query:
|
|
112
|
+
return "ERROR: read requires name or query"
|
|
113
|
+
target = root / f"{(name or query)}.md"
|
|
114
|
+
if not target.exists():
|
|
115
|
+
# fuzzy match by substring
|
|
116
|
+
matches = [p for p in root.glob("*.md")
|
|
117
|
+
if (name or query or "") in p.stem]
|
|
118
|
+
if not matches:
|
|
119
|
+
return f"ERROR: no {kind} file named {name or query}"
|
|
120
|
+
target = matches[0]
|
|
121
|
+
return target.read_text(encoding="utf-8")
|
|
122
|
+
if action in ("save", "update"):
|
|
123
|
+
if not name:
|
|
124
|
+
return "ERROR: save/update requires name"
|
|
125
|
+
if body is None:
|
|
126
|
+
return "ERROR: save/update requires body"
|
|
127
|
+
path = root / f"{name}.md"
|
|
128
|
+
existed = path.exists()
|
|
129
|
+
content = self._render(kind, name, body, tags)
|
|
130
|
+
missing = validate_markdown(kind, content)
|
|
131
|
+
if missing:
|
|
132
|
+
return (f"ERROR: {kind} '{name}' is missing required elements: "
|
|
133
|
+
f"{', '.join(missing)}. Use the template shape.")
|
|
134
|
+
path.write_text(content, encoding="utf-8")
|
|
135
|
+
verb = "Updated" if existed else "Created"
|
|
136
|
+
return f"{verb} {kind}: {path.name}"
|
|
137
|
+
return f"ERROR: unknown action {action}"
|
|
138
|
+
|
|
139
|
+
@staticmethod
|
|
140
|
+
def _render(kind: str, name: str, body: str,
|
|
141
|
+
tags: list[str] | None) -> str:
|
|
142
|
+
"""Normalize body into the required template shape for ``kind``.
|
|
143
|
+
|
|
144
|
+
Emits the full YAML front-matter with every required field so the
|
|
145
|
+
artifact passes template validation and is machine-readable.
|
|
146
|
+
"""
|
|
147
|
+
from ..templates import template_for
|
|
148
|
+
|
|
149
|
+
tpl = template_for(kind)
|
|
150
|
+
tag_csv = ",".join(tags or [])
|
|
151
|
+
now = _now()
|
|
152
|
+
# Map required front-matter fields to values we can derive.
|
|
153
|
+
fm_values = {"name": name, "topic": name, "category": name,
|
|
154
|
+
"goal": name, "updated": now, "created": now,
|
|
155
|
+
"tags": f"[{tag_csv}]", "phase": name, "index": "0",
|
|
156
|
+
"iteration": "0", "status": "draft", "phases": "0"}
|
|
157
|
+
lines = ["---"]
|
|
158
|
+
for fld in tpl.required_fields:
|
|
159
|
+
lines.append(f"{fld}: {fm_values.get(fld, '')}")
|
|
160
|
+
front = "\n".join(lines) + "\n---\n\n"
|
|
161
|
+
if body.startswith("#"):
|
|
162
|
+
return front + body + "\n"
|
|
163
|
+
return f"{front}# {name}\n\n{body}\n"
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Memory tool: Obsidian-style tagged markdown memory stored under modules/memory.
|
|
2
|
+
|
|
3
|
+
Supports saving notes (with #tags), listing, and searching across memory files.
|
|
4
|
+
Workspace-specific memory lives in <workspace>/.opencommand/memory/.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from ...core.tools import BaseTool
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def memory_dir(workspace: Path) -> Path:
|
|
15
|
+
d = workspace / ".opencommand" / "memory"
|
|
16
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
return d
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def save_note(workspace: Path, topic: str, body: str, tags: list[str] | None = None) -> str:
|
|
21
|
+
d = memory_dir(workspace)
|
|
22
|
+
tags = tags or []
|
|
23
|
+
tag_line = " " + " ".join(f"#{t}" for t in tags) if tags else ""
|
|
24
|
+
path = d / f"{topic.replace(' ', '_').lower()}.md"
|
|
25
|
+
path.write_text(f"# {topic}{tag_line}\n\n{body}\n", encoding="utf-8")
|
|
26
|
+
return f"Saved memory: {path.name}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def search_memory(workspace: Path, query: str) -> str:
|
|
30
|
+
d = memory_dir(workspace)
|
|
31
|
+
if not d.exists():
|
|
32
|
+
return "(no memory yet)"
|
|
33
|
+
q = query.lower()
|
|
34
|
+
hits: list[str] = []
|
|
35
|
+
for f in d.glob("*.md"):
|
|
36
|
+
text = f.read_text(encoding="utf-8")
|
|
37
|
+
if q in text.lower():
|
|
38
|
+
hits.append(f"- {f.name}: {text[:400].replace(chr(10), ' ')}")
|
|
39
|
+
return "\n".join(hits) if hits else "(no matches)"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def list_memory(workspace: Path) -> str:
|
|
43
|
+
d = memory_dir(workspace)
|
|
44
|
+
if not d.exists():
|
|
45
|
+
return "(no memory yet)"
|
|
46
|
+
files = sorted(p.name for p in d.glob("*.md"))
|
|
47
|
+
return "\n".join(files) if files else "(empty)"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class MemoryTool(BaseTool):
|
|
51
|
+
name = "memory"
|
|
52
|
+
description = "Manage OpenCommand memory: save a tagged note, list notes, or search notes."
|
|
53
|
+
parameters = {
|
|
54
|
+
"type": "object",
|
|
55
|
+
"properties": {
|
|
56
|
+
"action": {"type": "string", "enum": ["save", "list", "search"]},
|
|
57
|
+
"topic": {"type": "string", "description": "Note topic (for save)."},
|
|
58
|
+
"body": {"type": "string", "description": "Note body (for save)."},
|
|
59
|
+
"tags": {"type": "array", "items": {"type": "string"}, "description": "Tags (for save)."},
|
|
60
|
+
"query": {"type": "string", "description": "Search query (for search)."},
|
|
61
|
+
},
|
|
62
|
+
"required": ["action"],
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
def __init__(self, workspace: Path) -> None:
|
|
66
|
+
self.workspace = workspace
|
|
67
|
+
|
|
68
|
+
def run(self, action: str, topic: str | None = None, body: str | None = None,
|
|
69
|
+
tags: list[str] | None = None, query: str | None = None) -> str:
|
|
70
|
+
if action == "save":
|
|
71
|
+
if not topic or body is None:
|
|
72
|
+
return "ERROR: save requires topic and body"
|
|
73
|
+
return save_note(self.workspace, topic, body, tags)
|
|
74
|
+
if action == "list":
|
|
75
|
+
return list_memory(self.workspace)
|
|
76
|
+
if action == "search":
|
|
77
|
+
return search_memory(self.workspace, query or "")
|
|
78
|
+
return f"ERROR: unknown action {action}"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Playwright browser automation tool (web testing / interaction).
|
|
2
|
+
|
|
3
|
+
Distinct from research: drives a real browser (click, type, navigate, extract
|
|
4
|
+
DOM) for interactive web testing. Requires playwright + browsers installed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from ...core.tools import BaseTool
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PlaywrightTool(BaseTool):
|
|
13
|
+
name = "playwright"
|
|
14
|
+
description = "Automate a browser: navigate, click, type, read page text/DOM."
|
|
15
|
+
parameters = {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"properties": {
|
|
18
|
+
"action": {"type": "string", "enum": ["goto", "click", "type", "text", "screenshot"]},
|
|
19
|
+
"url": {"type": "string", "description": "URL for goto."},
|
|
20
|
+
"selector": {"type": "string", "description": "CSS selector for click/type."},
|
|
21
|
+
"value": {"type": "string", "description": "Text to type."},
|
|
22
|
+
},
|
|
23
|
+
"required": ["action"],
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
def __init__(self, headless: bool = True) -> None:
|
|
27
|
+
self.headless = headless
|
|
28
|
+
self._page = None
|
|
29
|
+
|
|
30
|
+
def _ensure(self):
|
|
31
|
+
if self._page is None:
|
|
32
|
+
from playwright.sync_api import sync_playwright
|
|
33
|
+
self._pw = sync_playwright().start()
|
|
34
|
+
self._browser = self._pw.chromium.launch(headless=self.headless)
|
|
35
|
+
self._page = self._browser.new_page()
|
|
36
|
+
return self._page
|
|
37
|
+
|
|
38
|
+
def run(self, action: str, url: str | None = None,
|
|
39
|
+
selector: str | None = None, value: str | None = None) -> str:
|
|
40
|
+
page = self._ensure()
|
|
41
|
+
if action == "goto" and url:
|
|
42
|
+
page.goto(url)
|
|
43
|
+
return f"Navigated to {url}; title={page.title()}"
|
|
44
|
+
if action == "click" and selector:
|
|
45
|
+
page.click(selector)
|
|
46
|
+
return f"Clicked {selector}"
|
|
47
|
+
if action == "type" and selector and value is not None:
|
|
48
|
+
page.fill(selector, value)
|
|
49
|
+
return f"Typed into {selector}"
|
|
50
|
+
if action == "text":
|
|
51
|
+
return page.inner_text("body")[:4000]
|
|
52
|
+
if action == "screenshot":
|
|
53
|
+
path = "/tmp/opencommand_shot.png"
|
|
54
|
+
page.screenshot(path=path)
|
|
55
|
+
return f"Screenshot saved: {path}"
|
|
56
|
+
return "ERROR: invalid action/args"
|
|
57
|
+
|
|
58
|
+
def close(self) -> None:
|
|
59
|
+
if getattr(self, "_browser", None):
|
|
60
|
+
self._browser.close()
|
|
61
|
+
self._pw.stop()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Research tool: multi-batch web research via HTTP + lightweight scraping.
|
|
2
|
+
|
|
3
|
+
Not a browser-automation tool (see playwright.py). Fetches pages and pulls out
|
|
4
|
+
readable text for research/planning agents.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
|
|
13
|
+
from ...core.tools import BaseTool
|
|
14
|
+
|
|
15
|
+
_TAG_RE = re.compile(r"<[^>]+>")
|
|
16
|
+
_WS_RE = re.compile(r"\s+")
|
|
17
|
+
_HEAD_RE = re.compile(r"<(script|style|head)[^>]*>.*?</\1>", re.IGNORECASE | re.DOTALL)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def fetch_text(url: str, timeout: int = 30) -> str:
|
|
21
|
+
resp = requests.get(url, timeout=timeout, headers={"User-Agent": "OpenCommand/0.1"})
|
|
22
|
+
resp.raise_for_status()
|
|
23
|
+
text = _WS_RE.sub(" ", _TAG_RE.sub(" ", _HEAD_RE.sub("", resp.text))).strip()
|
|
24
|
+
return text[:8000]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ResearchTool(BaseTool):
|
|
28
|
+
name = "research"
|
|
29
|
+
description = "Fetch one or more URLs and return cleaned readable text for research."
|
|
30
|
+
parameters = {
|
|
31
|
+
"type": "object",
|
|
32
|
+
"properties": {
|
|
33
|
+
"urls": {"type": "array", "items": {"type": "string"}, "description": "URLs to fetch."},
|
|
34
|
+
"query": {"type": "string", "description": "Optional note describing the research goal."},
|
|
35
|
+
},
|
|
36
|
+
"required": ["urls"],
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
def run(self, urls: list[str], query: str | None = None) -> str:
|
|
40
|
+
out: list[str] = []
|
|
41
|
+
for u in urls:
|
|
42
|
+
try:
|
|
43
|
+
out.append(f"## {u}\n{fetch_text(u)}")
|
|
44
|
+
except Exception as e: # noqa: BLE001
|
|
45
|
+
out.append(f"## {u}\nERROR: {e}")
|
|
46
|
+
return "\n\n".join(out)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""System introspection & security tool: processes, file watching, YARA scans.
|
|
2
|
+
|
|
3
|
+
Situational-awareness tool for the swarm: list running processes, scan files or
|
|
4
|
+
directories with YARA rules, and watch a path for changes via watchdog. YARA is
|
|
5
|
+
used through the yara CLI when present on PATH (the Python binding needs a C
|
|
6
|
+
toolchain to build on Windows, so we shell out instead). If yara is unavailable
|
|
7
|
+
the tool reports it clearly rather than crashing.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from ...core.tools import BaseTool
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _yara_available() -> bool:
|
|
21
|
+
return shutil.which("yara") is not None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def list_processes() -> list[dict[str, str]]:
|
|
25
|
+
"""Return a snapshot of running processes (name, pid, maybe cmdline)."""
|
|
26
|
+
out: list[dict[str, str]] = []
|
|
27
|
+
try:
|
|
28
|
+
proc = subprocess.run(["ps", "-eo", "pid,comm,pcpu,pmem"],
|
|
29
|
+
capture_output=True, text=True, timeout=20)
|
|
30
|
+
for line in proc.stdout.strip().splitlines()[1:]:
|
|
31
|
+
parts = line.split(None, 3)
|
|
32
|
+
if len(parts) >= 2:
|
|
33
|
+
out.append({
|
|
34
|
+
"pid": parts[0], "name": parts[1],
|
|
35
|
+
"cpu": parts[2] if len(parts) > 2 else "",
|
|
36
|
+
"mem": parts[3] if len(parts) > 3 else "",
|
|
37
|
+
})
|
|
38
|
+
except (FileNotFoundError, subprocess.SubprocessError):
|
|
39
|
+
# Windows fallback (wmic is deprecated; use tasklist).
|
|
40
|
+
proc = subprocess.run(["tasklist", "/fo", "csv", "/nh"],
|
|
41
|
+
capture_output=True, text=True, timeout=30)
|
|
42
|
+
for line in proc.stdout.strip().splitlines():
|
|
43
|
+
parts = [p.strip('"') for p in line.split('","')]
|
|
44
|
+
if len(parts) >= 2:
|
|
45
|
+
out.append({"pid": parts[1], "name": parts[0]})
|
|
46
|
+
return out
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def scan_file(path: str, rules: str) -> str:
|
|
50
|
+
"""Scan a single file with YARA rules (rules = path to .yar or rule text)."""
|
|
51
|
+
if not _yara_available():
|
|
52
|
+
return "ERROR: yara CLI not found on PATH (install YARA to enable scanning)"
|
|
53
|
+
target = Path(path)
|
|
54
|
+
if not target.exists():
|
|
55
|
+
return f"ERROR: path does not exist: {path}"
|
|
56
|
+
# If rules looks like a file, pass it; otherwise write a temp rule file.
|
|
57
|
+
rule_arg = rules
|
|
58
|
+
tmp = None
|
|
59
|
+
if not Path(rules).exists():
|
|
60
|
+
tmp = Path(target.parent) / ".opencommand_yara_tmp.yar"
|
|
61
|
+
tmp.write_text(rules, encoding="utf-8")
|
|
62
|
+
rule_arg = str(tmp)
|
|
63
|
+
try:
|
|
64
|
+
proc = subprocess.run(["yara", rule_arg, str(target)],
|
|
65
|
+
capture_output=True, text=True, timeout=60)
|
|
66
|
+
result = proc.stdout.strip() or "(no matches)"
|
|
67
|
+
if proc.stderr.strip():
|
|
68
|
+
result += f"\n[stderr] {proc.stderr.strip()}"
|
|
69
|
+
return result
|
|
70
|
+
finally:
|
|
71
|
+
if tmp and tmp.exists():
|
|
72
|
+
tmp.unlink(missing_ok=True)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def scan_directory(path: str, rules: str, recursive: bool = True) -> str:
|
|
76
|
+
"""Recursively scan a directory with YARA rules."""
|
|
77
|
+
if not _yara_available():
|
|
78
|
+
return "ERROR: yara CLI not found on PATH (install YARA to enable scanning)"
|
|
79
|
+
target = Path(path)
|
|
80
|
+
if not target.is_dir():
|
|
81
|
+
return f"ERROR: not a directory: {path}"
|
|
82
|
+
cmd = ["yara", "-r", rules, str(target)] if recursive else ["yara", rules, str(target)]
|
|
83
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
|
84
|
+
return proc.stdout.strip() or "(no matches)"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def watch_path(path: str, seconds: int = 10) -> str:
|
|
88
|
+
"""Watch a directory for filesystem changes for ``seconds`` and report them."""
|
|
89
|
+
try:
|
|
90
|
+
from watchdog.events import FileSystemEventHandler
|
|
91
|
+
from watchdog.observers import Observer
|
|
92
|
+
except Exception as e: # pragma: no cover
|
|
93
|
+
return f"ERROR: watchdog unavailable: {e}"
|
|
94
|
+
target = Path(path)
|
|
95
|
+
if not target.is_dir():
|
|
96
|
+
return f"ERROR: not a directory: {path}"
|
|
97
|
+
events: list[str] = []
|
|
98
|
+
|
|
99
|
+
class _Handler(FileSystemEventHandler):
|
|
100
|
+
def on_any_event(self, event):
|
|
101
|
+
events.append(f"{event.event_type}: {event.src_path}")
|
|
102
|
+
|
|
103
|
+
observer = Observer()
|
|
104
|
+
observer.schedule(_Handler(), str(target), recursive=True)
|
|
105
|
+
observer.start()
|
|
106
|
+
try:
|
|
107
|
+
import time
|
|
108
|
+
time.sleep(seconds)
|
|
109
|
+
finally:
|
|
110
|
+
observer.stop()
|
|
111
|
+
observer.join(timeout=5)
|
|
112
|
+
return "\n".join(events) if events else "(no changes observed)"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class SystemTool(BaseTool):
|
|
116
|
+
name = "system"
|
|
117
|
+
description = (
|
|
118
|
+
"System introspection & security: list running processes, scan files or "
|
|
119
|
+
"directories with YARA rules, and watch a path for filesystem changes. "
|
|
120
|
+
"Use for situational awareness and safety checks."
|
|
121
|
+
)
|
|
122
|
+
parameters = {
|
|
123
|
+
"type": "object",
|
|
124
|
+
"properties": {
|
|
125
|
+
"action": {
|
|
126
|
+
"type": "string",
|
|
127
|
+
"enum": ["list_processes", "scan_file", "scan_directory", "watch"],
|
|
128
|
+
"description": "Which system operation to perform.",
|
|
129
|
+
},
|
|
130
|
+
"path": {"type": "string", "description": "File or directory path (for scan/watch)."},
|
|
131
|
+
"rules": {
|
|
132
|
+
"type": "string",
|
|
133
|
+
"description": "Path to a .yar rule file, or inline YARA rule text (for scans).",
|
|
134
|
+
},
|
|
135
|
+
"recursive": {
|
|
136
|
+
"type": "boolean",
|
|
137
|
+
"description": "Recurse into subdirectories for scan_directory (default true).",
|
|
138
|
+
},
|
|
139
|
+
"seconds": {
|
|
140
|
+
"type": "integer",
|
|
141
|
+
"description": "How long to watch a path in seconds (default 10).",
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
"required": ["action"],
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
def run(self, action: str, path: str | None = None, rules: str | None = None,
|
|
148
|
+
recursive: bool = True, seconds: int = 10) -> str:
|
|
149
|
+
if action == "list_processes":
|
|
150
|
+
return json.dumps(list_processes(), indent=2)
|
|
151
|
+
if action == "scan_file":
|
|
152
|
+
if not path or not rules:
|
|
153
|
+
return "ERROR: scan_file requires 'path' and 'rules'"
|
|
154
|
+
return scan_file(path, rules)
|
|
155
|
+
if action == "scan_directory":
|
|
156
|
+
if not path or not rules:
|
|
157
|
+
return "ERROR: scan_directory requires 'path' and 'rules'"
|
|
158
|
+
return scan_directory(path, rules, recursive=recursive)
|
|
159
|
+
if action == "watch":
|
|
160
|
+
if not path:
|
|
161
|
+
return "ERROR: watch requires 'path'"
|
|
162
|
+
return watch_path(path, seconds=seconds)
|
|
163
|
+
return f"ERROR: unknown action '{action}'"
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Cross-platform terminal command execution tool.
|
|
2
|
+
|
|
3
|
+
Agents call Terminal.run to execute shell commands and get the output back.
|
|
4
|
+
Uses subprocess (not os.popen, which is soft-deprecated in 3.14) and detects
|
|
5
|
+
the platform shell automatically.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from ...core.tools import BaseTool
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class TerminalResult:
|
|
19
|
+
returncode: int
|
|
20
|
+
stdout: str
|
|
21
|
+
stderr: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _shell() -> list[str]:
|
|
25
|
+
return ["cmd.exe", "/c"] if sys.platform.startswith("win") else ["/bin/sh", "-c"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def run_command(command: str, cwd: str | None = None, timeout: int = 120) -> TerminalResult:
|
|
29
|
+
"""Run ``command`` in the platform shell; return stdout/stderr/rc."""
|
|
30
|
+
proc = subprocess.run(_shell() + [command], cwd=cwd, capture_output=True,
|
|
31
|
+
text=True, timeout=timeout)
|
|
32
|
+
return TerminalResult(proc.returncode, proc.stdout, proc.stderr)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TerminalTool(BaseTool):
|
|
36
|
+
name = "terminal"
|
|
37
|
+
description = "Run a shell command on the host and return its stdout/stderr/exit code."
|
|
38
|
+
parameters = {
|
|
39
|
+
"type": "object",
|
|
40
|
+
"properties": {
|
|
41
|
+
"command": {"type": "string", "description": "The shell command to run."},
|
|
42
|
+
"cwd": {"type": "string", "description": "Working directory (optional)."},
|
|
43
|
+
"timeout": {"type": "integer", "description": "Timeout in seconds (default 120)."},
|
|
44
|
+
},
|
|
45
|
+
"required": ["command"],
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
def run(self, command: str, cwd: str | None = None, timeout: int = 600) -> str:
|
|
49
|
+
try:
|
|
50
|
+
r = run_command(command, cwd=cwd, timeout=timeout)
|
|
51
|
+
except subprocess.TimeoutExpired:
|
|
52
|
+
return "ERROR: command timed out"
|
|
53
|
+
return f"[exit={r.returncode}]\nSTDOUT:\n{r.stdout}\nSTDERR:\n{r.stderr}"
|