elizaos-plugin-code 2.0.0a4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. elizaos_plugin_code-2.0.0a4/.gitignore +12 -0
  2. elizaos_plugin_code-2.0.0a4/PKG-INFO +30 -0
  3. elizaos_plugin_code-2.0.0a4/README.md +4 -0
  4. elizaos_plugin_code-2.0.0a4/elizaos_plugin_eliza_coder/__init__.py +58 -0
  5. elizaos_plugin_code-2.0.0a4/elizaos_plugin_eliza_coder/actions/__init__.py +19 -0
  6. elizaos_plugin_code-2.0.0a4/elizaos_plugin_eliza_coder/actions/change_directory.py +32 -0
  7. elizaos_plugin_code-2.0.0a4/elizaos_plugin_eliza_coder/actions/common.py +25 -0
  8. elizaos_plugin_code-2.0.0a4/elizaos_plugin_eliza_coder/actions/edit_file.py +33 -0
  9. elizaos_plugin_code-2.0.0a4/elizaos_plugin_eliza_coder/actions/execute_shell.py +32 -0
  10. elizaos_plugin_code-2.0.0a4/elizaos_plugin_eliza_coder/actions/git.py +32 -0
  11. elizaos_plugin_code-2.0.0a4/elizaos_plugin_eliza_coder/actions/list_files.py +29 -0
  12. elizaos_plugin_code-2.0.0a4/elizaos_plugin_eliza_coder/actions/read_file.py +37 -0
  13. elizaos_plugin_code-2.0.0a4/elizaos_plugin_eliza_coder/actions/search_files.py +42 -0
  14. elizaos_plugin_code-2.0.0a4/elizaos_plugin_eliza_coder/actions/write_file.py +32 -0
  15. elizaos_plugin_code-2.0.0a4/elizaos_plugin_eliza_coder/config.py +40 -0
  16. elizaos_plugin_code-2.0.0a4/elizaos_plugin_eliza_coder/path_utils.py +54 -0
  17. elizaos_plugin_code-2.0.0a4/elizaos_plugin_eliza_coder/providers/__init__.py +3 -0
  18. elizaos_plugin_code-2.0.0a4/elizaos_plugin_eliza_coder/providers/coder_status.py +64 -0
  19. elizaos_plugin_code-2.0.0a4/elizaos_plugin_eliza_coder/py.typed +1 -0
  20. elizaos_plugin_code-2.0.0a4/elizaos_plugin_eliza_coder/service.py +351 -0
  21. elizaos_plugin_code-2.0.0a4/elizaos_plugin_eliza_coder/types.py +42 -0
  22. elizaos_plugin_code-2.0.0a4/pyproject.toml +79 -0
  23. elizaos_plugin_code-2.0.0a4/tests/conftest.py +27 -0
  24. elizaos_plugin_code-2.0.0a4/tests/test_actions.py +87 -0
  25. elizaos_plugin_code-2.0.0a4/tests/test_provider.py +12 -0
@@ -0,0 +1,12 @@
1
+ dist
2
+ node_modules
3
+ .env
4
+ .elizadb
5
+ .turbo
6
+ target/
7
+ __pycache__
8
+ *.pyc
9
+ .venv
10
+ *.egg-info
11
+ .DS_Store
12
+ package-lock.json
@@ -0,0 +1,30 @@
1
+ Metadata-Version: 2.4
2
+ Name: elizaos-plugin-code
3
+ Version: 2.0.0a4
4
+ Summary: elizaOS Coder Plugin - filesystem + shell + git (restricted)
5
+ Project-URL: Homepage, https://github.com/elizaos/eliza
6
+ Project-URL: Repository, https://github.com/elizaos/eliza
7
+ Author: elizaOS Contributors
8
+ License-Expression: MIT
9
+ Keywords: agents,ai,coding,elizaos,filesystem,git,shell
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.11
18
+ Requires-Dist: pydantic>=2.10.0
19
+ Provides-Extra: dev
20
+ Requires-Dist: mypy>=1.14.0; extra == 'dev'
21
+ Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
22
+ Requires-Dist: pytest-cov>=6.0.0; extra == 'dev'
23
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
24
+ Requires-Dist: ruff>=0.9.0; extra == 'dev'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # elizaos-plugin-code (Python)
28
+
29
+ Python implementation of the Eliza Coder plugin: filesystem + shell + git, restricted to an allowed directory.
30
+
@@ -0,0 +1,4 @@
1
+ # elizaos-plugin-code (Python)
2
+
3
+ Python implementation of the Eliza Coder plugin: filesystem + shell + git, restricted to an allowed directory.
4
+
@@ -0,0 +1,58 @@
1
+ from elizaos_plugin_eliza_coder.actions import (
2
+ ChangeDirectoryAction,
3
+ EditFileAction,
4
+ ExecuteShellAction,
5
+ GitAction,
6
+ ListFilesAction,
7
+ ReadFileAction,
8
+ SearchFilesAction,
9
+ WriteFileAction,
10
+ )
11
+ from elizaos_plugin_eliza_coder.config import load_coder_config
12
+ from elizaos_plugin_eliza_coder.path_utils import (
13
+ DEFAULT_FORBIDDEN_COMMANDS,
14
+ extract_base_command,
15
+ is_forbidden_command,
16
+ is_safe_command,
17
+ validate_path,
18
+ )
19
+ from elizaos_plugin_eliza_coder.providers import CoderStatusProvider
20
+ from elizaos_plugin_eliza_coder.service import CoderService
21
+ from elizaos_plugin_eliza_coder.types import (
22
+ CoderConfig,
23
+ CommandHistoryEntry,
24
+ CommandResult,
25
+ FileOperation,
26
+ FileOperationType,
27
+ )
28
+
29
+ __version__ = "1.0.0"
30
+
31
+ PLUGIN_NAME = "code"
32
+ PLUGIN_DESCRIPTION = "Filesystem + shell + git tools within a restricted directory"
33
+
34
+ __all__ = [
35
+ "CoderConfig",
36
+ "CommandResult",
37
+ "CommandHistoryEntry",
38
+ "FileOperation",
39
+ "FileOperationType",
40
+ "CoderService",
41
+ "ReadFileAction",
42
+ "WriteFileAction",
43
+ "EditFileAction",
44
+ "ListFilesAction",
45
+ "SearchFilesAction",
46
+ "ChangeDirectoryAction",
47
+ "ExecuteShellAction",
48
+ "GitAction",
49
+ "CoderStatusProvider",
50
+ "load_coder_config",
51
+ "validate_path",
52
+ "is_safe_command",
53
+ "extract_base_command",
54
+ "is_forbidden_command",
55
+ "DEFAULT_FORBIDDEN_COMMANDS",
56
+ "PLUGIN_NAME",
57
+ "PLUGIN_DESCRIPTION",
58
+ ]
@@ -0,0 +1,19 @@
1
+ from elizaos_plugin_eliza_coder.actions.change_directory import ChangeDirectoryAction
2
+ from elizaos_plugin_eliza_coder.actions.edit_file import EditFileAction
3
+ from elizaos_plugin_eliza_coder.actions.execute_shell import ExecuteShellAction
4
+ from elizaos_plugin_eliza_coder.actions.git import GitAction
5
+ from elizaos_plugin_eliza_coder.actions.list_files import ListFilesAction
6
+ from elizaos_plugin_eliza_coder.actions.read_file import ReadFileAction
7
+ from elizaos_plugin_eliza_coder.actions.search_files import SearchFilesAction
8
+ from elizaos_plugin_eliza_coder.actions.write_file import WriteFileAction
9
+
10
+ __all__ = [
11
+ "ReadFileAction",
12
+ "WriteFileAction",
13
+ "EditFileAction",
14
+ "ListFilesAction",
15
+ "SearchFilesAction",
16
+ "ChangeDirectoryAction",
17
+ "ExecuteShellAction",
18
+ "GitAction",
19
+ ]
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from elizaos_plugin_eliza_coder.actions.common import ActionResult, Message, conversation_id
6
+ from elizaos_plugin_eliza_coder.service import CoderService
7
+
8
+
9
+ @dataclass
10
+ class ChangeDirectoryAction:
11
+ @property
12
+ def name(self) -> str:
13
+ return "CHANGE_DIRECTORY"
14
+
15
+ async def validate(self, _message: Message, _state: dict) -> bool:
16
+ return True
17
+
18
+ async def handler(
19
+ self,
20
+ message: Message,
21
+ state: dict,
22
+ service: CoderService | None = None,
23
+ ) -> ActionResult:
24
+ if service is None:
25
+ return ActionResult(False, "Coder service is not available.", "missing_service")
26
+
27
+ target = str(state.get("path", "")).strip()
28
+ if not target:
29
+ return ActionResult(False, "Missing path.", "missing_path")
30
+
31
+ result = await service.change_directory(conversation_id(message), target)
32
+ return ActionResult(result.success, result.stdout if result.success else result.stderr)
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TypedDict
5
+
6
+
7
+ class MessageContent(TypedDict, total=False):
8
+ text: str
9
+
10
+
11
+ class Message(TypedDict, total=False):
12
+ content: MessageContent
13
+ room_id: str
14
+ agent_id: str
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class ActionResult:
19
+ success: bool
20
+ text: str
21
+ error: str | None = None
22
+
23
+
24
+ def conversation_id(message: Message) -> str:
25
+ return message.get("room_id") or message.get("agent_id") or "default"
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from elizaos_plugin_eliza_coder.actions.common import ActionResult, Message, conversation_id
6
+ from elizaos_plugin_eliza_coder.service import CoderService
7
+
8
+
9
+ @dataclass
10
+ class EditFileAction:
11
+ @property
12
+ def name(self) -> str:
13
+ return "EDIT_FILE"
14
+
15
+ async def validate(self, _message: Message, _state: dict) -> bool:
16
+ return True
17
+
18
+ async def handler(
19
+ self, message: Message, state: dict, service: CoderService | None = None
20
+ ) -> ActionResult:
21
+ if service is None:
22
+ return ActionResult(False, "Coder service is not available.", "missing_service")
23
+
24
+ filepath = str(state.get("filepath", "")).strip()
25
+ old_str = str(state.get("old_str", ""))
26
+ new_str = str(state.get("new_str", ""))
27
+ if not filepath or not old_str:
28
+ return ActionResult(False, "Missing filepath or old_str.", "missing_args")
29
+
30
+ ok, err = await service.edit_file(conversation_id(message), filepath, old_str, new_str)
31
+ if not ok:
32
+ return ActionResult(False, err, "edit_failed")
33
+ return ActionResult(True, f"Edited {filepath}")
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from elizaos_plugin_eliza_coder.actions.common import ActionResult, Message, conversation_id
6
+ from elizaos_plugin_eliza_coder.service import CoderService
7
+
8
+
9
+ @dataclass
10
+ class ExecuteShellAction:
11
+ @property
12
+ def name(self) -> str:
13
+ return "EXECUTE_SHELL"
14
+
15
+ async def validate(self, _message: Message, _state: dict) -> bool:
16
+ return True
17
+
18
+ async def handler(
19
+ self,
20
+ message: Message,
21
+ state: dict,
22
+ service: CoderService | None = None,
23
+ ) -> ActionResult:
24
+ if service is None:
25
+ return ActionResult(False, "Coder service is not available.", "missing_service")
26
+
27
+ cmd = str(state.get("command", "")).strip()
28
+ if not cmd:
29
+ return ActionResult(False, "Missing command.", "missing_command")
30
+
31
+ res = await service.execute_shell(conversation_id(message), cmd)
32
+ return ActionResult(res.success, res.stdout if res.success else res.stderr)
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from elizaos_plugin_eliza_coder.actions.common import ActionResult, Message, conversation_id
6
+ from elizaos_plugin_eliza_coder.service import CoderService
7
+
8
+
9
+ @dataclass
10
+ class GitAction:
11
+ @property
12
+ def name(self) -> str:
13
+ return "GIT"
14
+
15
+ async def validate(self, _message: Message, _state: dict) -> bool:
16
+ return True
17
+
18
+ async def handler(
19
+ self,
20
+ message: Message,
21
+ state: dict,
22
+ service: CoderService | None = None,
23
+ ) -> ActionResult:
24
+ if service is None:
25
+ return ActionResult(False, "Coder service is not available.", "missing_service")
26
+
27
+ args = str(state.get("args", "")).strip()
28
+ if not args:
29
+ return ActionResult(False, "Missing args.", "missing_args")
30
+
31
+ res = await service.git(conversation_id(message), args)
32
+ return ActionResult(res.success, res.stdout if res.success else res.stderr)
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from elizaos_plugin_eliza_coder.actions.common import ActionResult, Message, conversation_id
6
+ from elizaos_plugin_eliza_coder.service import CoderService
7
+
8
+
9
+ @dataclass
10
+ class ListFilesAction:
11
+ @property
12
+ def name(self) -> str:
13
+ return "LIST_FILES"
14
+
15
+ async def validate(self, _message: Message, _state: dict) -> bool:
16
+ return True
17
+
18
+ async def handler(
19
+ self, message: Message, state: dict, service: CoderService | None = None
20
+ ) -> ActionResult:
21
+ if service is None:
22
+ return ActionResult(False, "Coder service is not available.", "missing_service")
23
+
24
+ path_arg = str(state.get("path", ".")).strip() or "."
25
+ ok, res = await service.list_files(conversation_id(message), path_arg)
26
+ if not ok:
27
+ return ActionResult(False, str(res), "list_failed")
28
+ items = res if isinstance(res, list) else []
29
+ return ActionResult(True, "\n".join(items) if items else "(empty)")
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from elizaos_plugin_eliza_coder.actions.common import ActionResult, Message, conversation_id
6
+ from elizaos_plugin_eliza_coder.service import CoderService
7
+
8
+
9
+ @dataclass
10
+ class ReadFileAction:
11
+ @property
12
+ def name(self) -> str:
13
+ return "READ_FILE"
14
+
15
+ async def validate(self, _message: Message, _state: dict) -> bool:
16
+ return True
17
+
18
+ async def handler(
19
+ self, message: Message, _state: dict, service: CoderService | None = None
20
+ ) -> ActionResult:
21
+ if service is None:
22
+ return ActionResult(False, "Coder service is not available.", "missing_service")
23
+
24
+ text = (message.get("content", {}) or {}).get("text", "")
25
+ filepath = ""
26
+ if '"' in text:
27
+ parts = text.split('"')
28
+ if len(parts) >= 2:
29
+ filepath = parts[1]
30
+
31
+ if not filepath:
32
+ return ActionResult(False, "Missing filepath.", "missing_filepath")
33
+
34
+ ok, out = await service.read_file(conversation_id(message), filepath)
35
+ if not ok:
36
+ return ActionResult(False, out, "read_failed")
37
+ return ActionResult(True, out)
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from elizaos_plugin_eliza_coder.actions.common import ActionResult, Message, conversation_id
6
+ from elizaos_plugin_eliza_coder.service import CoderService, SearchMatch
7
+
8
+
9
+ @dataclass
10
+ class SearchFilesAction:
11
+ @property
12
+ def name(self) -> str:
13
+ return "SEARCH_FILES"
14
+
15
+ async def validate(self, _message: Message, _state: dict) -> bool:
16
+ return True
17
+
18
+ async def handler(
19
+ self, message: Message, state: dict, service: CoderService | None = None
20
+ ) -> ActionResult:
21
+ if service is None:
22
+ return ActionResult(False, "Coder service is not available.", "missing_service")
23
+
24
+ pattern = str(state.get("pattern", "")).strip()
25
+ dirpath = str(state.get("path", ".")).strip() or "."
26
+ max_matches = (
27
+ int(state.get("max_matches", 50)) if str(state.get("max_matches", "")).strip() else 50
28
+ )
29
+
30
+ if not pattern:
31
+ return ActionResult(False, "Missing pattern.", "missing_pattern")
32
+
33
+ ok, res = await service.search_files(
34
+ conversation_id(message), pattern, dirpath, max_matches
35
+ )
36
+ if not ok:
37
+ return ActionResult(False, str(res), "search_failed")
38
+ matches = res if isinstance(res, list) else []
39
+ if not matches:
40
+ return ActionResult(True, f'No matches for "{pattern}".')
41
+ lines = [f"{m.file}:L{m.line}: {m.content}" for m in matches if isinstance(m, SearchMatch)]
42
+ return ActionResult(True, "\n".join(lines))
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from elizaos_plugin_eliza_coder.actions.common import ActionResult, Message, conversation_id
6
+ from elizaos_plugin_eliza_coder.service import CoderService
7
+
8
+
9
+ @dataclass
10
+ class WriteFileAction:
11
+ @property
12
+ def name(self) -> str:
13
+ return "WRITE_FILE"
14
+
15
+ async def validate(self, _message: Message, _state: dict) -> bool:
16
+ return True
17
+
18
+ async def handler(
19
+ self, message: Message, state: dict, service: CoderService | None = None
20
+ ) -> ActionResult:
21
+ if service is None:
22
+ return ActionResult(False, "Coder service is not available.", "missing_service")
23
+
24
+ filepath = str(state.get("filepath", "")).strip()
25
+ content = str(state.get("content", ""))
26
+ if not filepath:
27
+ return ActionResult(False, "Missing filepath.", "missing_filepath")
28
+
29
+ ok, err = await service.write_file(conversation_id(message), filepath, content)
30
+ if not ok:
31
+ return ActionResult(False, err, "write_failed")
32
+ return ActionResult(True, f"Wrote {filepath} ({len(content)} chars)")
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from elizaos_plugin_eliza_coder.types import CoderConfig
7
+
8
+
9
+ def _parse_bool(value: str | None) -> bool:
10
+ v = (value or "").strip().lower()
11
+ return v in {"1", "true", "yes", "on"}
12
+
13
+
14
+ def _parse_int(value: str | None, fallback: int) -> int:
15
+ raw = (value or "").strip()
16
+ try:
17
+ parsed = int(raw)
18
+ except ValueError:
19
+ return fallback
20
+ if parsed <= 0:
21
+ return fallback
22
+ return parsed
23
+
24
+
25
+ def load_coder_config() -> CoderConfig:
26
+ enabled = _parse_bool(os.environ.get("CODER_ENABLED"))
27
+ allowed_raw = (os.environ.get("CODER_ALLOWED_DIRECTORY") or "").strip()
28
+ allowed = Path(allowed_raw).resolve() if allowed_raw else Path.cwd().resolve()
29
+ timeout_ms = _parse_int(os.environ.get("CODER_TIMEOUT"), 30_000)
30
+ forbidden = [
31
+ s.strip()
32
+ for s in (os.environ.get("CODER_FORBIDDEN_COMMANDS") or "").split(",")
33
+ if s.strip()
34
+ ]
35
+ return CoderConfig(
36
+ enabled=enabled,
37
+ allowed_directory=os.fspath(allowed),
38
+ timeout_ms=timeout_ms,
39
+ forbidden_commands=forbidden,
40
+ )
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ DEFAULT_FORBIDDEN_COMMANDS: list[str] = [
7
+ "rm -rf /",
8
+ "rm -rf ~",
9
+ "sudo rm",
10
+ "mkfs",
11
+ "dd if=/dev",
12
+ ]
13
+
14
+
15
+ def extract_base_command(command: str) -> str:
16
+ trimmed = command.strip()
17
+ if not trimmed:
18
+ return ""
19
+ return trimmed.split()[0]
20
+
21
+
22
+ def is_safe_command(command: str) -> bool:
23
+ c = command.strip()
24
+ if not c:
25
+ return False
26
+ if "&&" in c or "||" in c or ";" in c:
27
+ return False
28
+ if "$(" in c or "`" in c:
29
+ return False
30
+ return True
31
+
32
+
33
+ def is_forbidden_command(command: str, additional_forbidden: list[str]) -> bool:
34
+ lower = command.lower()
35
+ for f in DEFAULT_FORBIDDEN_COMMANDS:
36
+ if f.lower() in lower:
37
+ return True
38
+ for f in additional_forbidden:
39
+ if f.strip() and f.lower() in lower:
40
+ return True
41
+ return False
42
+
43
+
44
+ def validate_path(target_path: str, allowed_directory: str, current_directory: str) -> str | None:
45
+ allowed = Path(allowed_directory).resolve()
46
+ base = Path(current_directory).resolve() if current_directory else allowed
47
+ resolved = (base / target_path).resolve()
48
+
49
+ try:
50
+ resolved.relative_to(allowed)
51
+ except ValueError:
52
+ return None
53
+
54
+ return os.fspath(resolved)
@@ -0,0 +1,3 @@
1
+ from elizaos_plugin_eliza_coder.providers.coder_status import CoderStatusProvider
2
+
3
+ __all__ = ["CoderStatusProvider"]
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from elizaos_plugin_eliza_coder.actions.common import Message, conversation_id
6
+ from elizaos_plugin_eliza_coder.service import CoderService
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class ProviderResult:
11
+ values: dict[str, str]
12
+ text: str
13
+ data: dict[str, str | int]
14
+
15
+
16
+ class CoderStatusProvider:
17
+ @property
18
+ def name(self) -> str:
19
+ return "CODER_STATUS"
20
+
21
+ @property
22
+ def description(self) -> str:
23
+ return "Provides current working directory, allowed directory, and recent command history"
24
+
25
+ @property
26
+ def position(self) -> int:
27
+ return 99
28
+
29
+ async def get(
30
+ self,
31
+ message: Message,
32
+ _state: dict,
33
+ service: CoderService | None = None,
34
+ ) -> ProviderResult:
35
+ if service is None:
36
+ return ProviderResult(
37
+ values={
38
+ "coderStatus": "Coder service is not available",
39
+ "currentWorkingDirectory": "N/A",
40
+ "allowedDirectory": "N/A",
41
+ },
42
+ text="# Coder Status\n\nCoder service is not available",
43
+ data={"historyCount": 0, "cwd": "N/A", "allowedDir": "N/A"},
44
+ )
45
+
46
+ conv = conversation_id(message)
47
+ history = service.get_command_history(conv, limit=10)
48
+ cwd = service.get_current_directory(conv)
49
+ allowed = service.allowed_directory
50
+
51
+ history_text = "No commands in history."
52
+ if history:
53
+ history_text = "\n".join([f"{h.working_directory}> {h.command}" for h in history])
54
+
55
+ text = f"Current Directory: {cwd}\nAllowed Directory: {allowed}\n\n{history_text}"
56
+ return ProviderResult(
57
+ values={
58
+ "coderStatus": history_text,
59
+ "currentWorkingDirectory": cwd,
60
+ "allowedDirectory": allowed,
61
+ },
62
+ text=text,
63
+ data={"historyCount": len(history), "cwd": cwd, "allowedDir": allowed},
64
+ )
@@ -0,0 +1,351 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import shlex
6
+ import subprocess
7
+ import time
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ from elizaos_plugin_eliza_coder.config import load_coder_config
12
+ from elizaos_plugin_eliza_coder.path_utils import (
13
+ is_forbidden_command,
14
+ is_safe_command,
15
+ validate_path,
16
+ )
17
+ from elizaos_plugin_eliza_coder.types import (
18
+ CommandHistoryEntry,
19
+ CommandResult,
20
+ FileOperation,
21
+ )
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class SearchMatch:
28
+ file: str
29
+ line: int
30
+ content: str
31
+
32
+
33
+ class CoderService:
34
+ def __init__(self) -> None:
35
+ self._config = load_coder_config()
36
+ self._cwd_by_conversation: dict[str, str] = {}
37
+ self._history_by_conversation: dict[str, list[CommandHistoryEntry]] = {}
38
+ self._max_history = 100
39
+
40
+ @property
41
+ def allowed_directory(self) -> str:
42
+ return self._config.allowed_directory
43
+
44
+ def get_current_directory(self, conversation_id: str) -> str:
45
+ return self._cwd_by_conversation.get(conversation_id, self._config.allowed_directory)
46
+
47
+ def _set_current_directory(self, conversation_id: str, directory: str) -> None:
48
+ self._cwd_by_conversation[conversation_id] = directory
49
+
50
+ def get_command_history(self, conversation_id: str, limit: int) -> list[CommandHistoryEntry]:
51
+ if limit <= 0:
52
+ return []
53
+ return self._history_by_conversation.get(conversation_id, [])[-limit:]
54
+
55
+ def _add_history(
56
+ self,
57
+ conversation_id: str,
58
+ command: str,
59
+ result: CommandResult,
60
+ file_ops: list[FileOperation] | None = None,
61
+ ) -> None:
62
+ items = self._history_by_conversation.get(conversation_id, [])
63
+ items.append(
64
+ CommandHistoryEntry(
65
+ timestamp=time.time(),
66
+ working_directory=result.executed_in,
67
+ command=command,
68
+ stdout=result.stdout,
69
+ stderr=result.stderr,
70
+ exit_code=result.exit_code,
71
+ file_operations=file_ops,
72
+ )
73
+ )
74
+ if len(items) > self._max_history:
75
+ items = items[-self._max_history :]
76
+ self._history_by_conversation[conversation_id] = items
77
+
78
+ def _ensure_enabled(self) -> str | None:
79
+ if self._config.enabled:
80
+ return None
81
+ return "Coder plugin is disabled. Set CODER_ENABLED=true to enable."
82
+
83
+ def _resolve_within(self, conversation_id: str, target: str) -> str | None:
84
+ cwd = self.get_current_directory(conversation_id)
85
+ return validate_path(target, self._config.allowed_directory, cwd)
86
+
87
+ async def change_directory(self, conversation_id: str, target: str) -> CommandResult:
88
+ disabled = self._ensure_enabled()
89
+ if disabled:
90
+ return CommandResult(
91
+ success=False,
92
+ stdout="",
93
+ stderr=disabled,
94
+ exit_code=1,
95
+ error="Coder disabled",
96
+ executed_in=self.get_current_directory(conversation_id),
97
+ )
98
+
99
+ resolved = self._resolve_within(conversation_id, target)
100
+ if not resolved:
101
+ return CommandResult(
102
+ success=False,
103
+ stdout="",
104
+ stderr="Cannot navigate outside allowed directory",
105
+ exit_code=1,
106
+ error="Permission denied",
107
+ executed_in=self.get_current_directory(conversation_id),
108
+ )
109
+
110
+ p = Path(resolved)
111
+ if not p.is_dir():
112
+ return CommandResult(
113
+ success=False,
114
+ stdout="",
115
+ stderr="Not a directory",
116
+ exit_code=1,
117
+ error="Not a directory",
118
+ executed_in=self.get_current_directory(conversation_id),
119
+ )
120
+
121
+ self._set_current_directory(conversation_id, resolved)
122
+ return CommandResult(
123
+ success=True,
124
+ stdout=f"Changed directory to: {resolved}",
125
+ stderr="",
126
+ exit_code=0,
127
+ executed_in=resolved,
128
+ )
129
+
130
+ async def read_file(self, conversation_id: str, filepath: str) -> tuple[bool, str]:
131
+ disabled = self._ensure_enabled()
132
+ if disabled:
133
+ return False, disabled
134
+
135
+ resolved = self._resolve_within(conversation_id, filepath)
136
+ if not resolved:
137
+ return False, "Cannot access path outside allowed directory"
138
+
139
+ p = Path(resolved)
140
+ if p.is_dir():
141
+ return False, "Path is a directory"
142
+ if not p.exists():
143
+ return False, "File not found"
144
+ return True, p.read_text(encoding="utf-8")
145
+
146
+ async def write_file(
147
+ self, conversation_id: str, filepath: str, content: str
148
+ ) -> tuple[bool, str]:
149
+ """Write content to a file, creating parent directories as needed."""
150
+ disabled = self._ensure_enabled()
151
+ if disabled:
152
+ return False, disabled
153
+
154
+ if not filepath or not filepath.strip():
155
+ return False, "File path cannot be empty"
156
+
157
+ resolved = self._resolve_within(conversation_id, filepath)
158
+ if not resolved:
159
+ return False, "Cannot access path outside allowed directory"
160
+
161
+ p = Path(resolved)
162
+ if p.parent != Path(resolved).parent or str(p.parent):
163
+ p.parent.mkdir(parents=True, exist_ok=True)
164
+ p.write_text(content, encoding="utf-8")
165
+ return True, ""
166
+
167
+ async def edit_file(
168
+ self, conversation_id: str, filepath: str, old_str: str, new_str: str
169
+ ) -> tuple[bool, str]:
170
+ disabled = self._ensure_enabled()
171
+ if disabled:
172
+ return False, disabled
173
+
174
+ resolved = self._resolve_within(conversation_id, filepath)
175
+ if not resolved:
176
+ return False, "Cannot access path outside allowed directory"
177
+
178
+ p = Path(resolved)
179
+ if not p.exists():
180
+ return False, "File not found"
181
+ content = p.read_text(encoding="utf-8")
182
+ if old_str not in content:
183
+ return False, "Could not find old_str in file"
184
+ p.write_text(content.replace(old_str, new_str, 1), encoding="utf-8")
185
+ return True, ""
186
+
187
+ async def list_files(self, conversation_id: str, dirpath: str) -> tuple[bool, list[str] | str]:
188
+ disabled = self._ensure_enabled()
189
+ if disabled:
190
+ return False, disabled
191
+
192
+ resolved = self._resolve_within(conversation_id, dirpath)
193
+ if not resolved:
194
+ return False, "Cannot access path outside allowed directory"
195
+
196
+ p = Path(resolved)
197
+ if not p.exists():
198
+ return False, "Directory not found"
199
+ if not p.is_dir():
200
+ return False, "Not a directory"
201
+
202
+ items: list[str] = []
203
+ for child in sorted(p.iterdir(), key=lambda x: x.name):
204
+ if child.name.startswith("."):
205
+ continue
206
+ items.append(f"{child.name}/" if child.is_dir() else child.name)
207
+ return True, items
208
+
209
+ async def search_files(
210
+ self, conversation_id: str, pattern: str, dirpath: str, max_matches: int
211
+ ) -> tuple[bool, list[SearchMatch] | str]:
212
+ disabled = self._ensure_enabled()
213
+ if disabled:
214
+ return False, disabled
215
+
216
+ needle = pattern.strip()
217
+ if not needle:
218
+ return False, "Missing pattern"
219
+
220
+ resolved = self._resolve_within(conversation_id, dirpath)
221
+ if not resolved:
222
+ return False, "Cannot access path outside allowed directory"
223
+
224
+ limit = max(1, min(500, int(max_matches))) if max_matches > 0 else 50
225
+ matches: list[SearchMatch] = []
226
+ await self._search_dir(Path(resolved), needle.lower(), matches, limit)
227
+ return True, matches
228
+
229
+ async def _search_dir(
230
+ self, dirpath: Path, needle_lower: str, matches: list[SearchMatch], limit: int
231
+ ) -> None:
232
+ if len(matches) >= limit:
233
+ return
234
+ for entry in sorted(dirpath.iterdir(), key=lambda x: x.name):
235
+ if len(matches) >= limit:
236
+ break
237
+ if entry.name.startswith("."):
238
+ continue
239
+ if entry.is_dir():
240
+ if entry.name in {"node_modules", "dist", "build", "coverage", ".git"}:
241
+ continue
242
+ await self._search_dir(entry, needle_lower, matches, limit)
243
+ continue
244
+ if not entry.is_file():
245
+ continue
246
+ try:
247
+ text = entry.read_text(encoding="utf-8")
248
+ except UnicodeDecodeError:
249
+ continue
250
+ for idx, line in enumerate(text.splitlines(), start=1):
251
+ if len(matches) >= limit:
252
+ break
253
+ if needle_lower in line.lower():
254
+ matches.append(
255
+ SearchMatch(
256
+ file=str(entry.relative_to(Path(self.allowed_directory))),
257
+ line=idx,
258
+ content=line.strip()[:240],
259
+ )
260
+ )
261
+
262
+ async def execute_shell(self, conversation_id: str, command: str) -> CommandResult:
263
+ disabled = self._ensure_enabled()
264
+ if disabled:
265
+ return CommandResult(
266
+ success=False,
267
+ stdout="",
268
+ stderr=disabled,
269
+ exit_code=1,
270
+ error="Coder disabled",
271
+ executed_in=self.get_current_directory(conversation_id),
272
+ )
273
+
274
+ trimmed = command.strip()
275
+ if not trimmed:
276
+ return CommandResult(
277
+ success=False,
278
+ stdout="",
279
+ stderr="Invalid command",
280
+ exit_code=1,
281
+ error="Empty command",
282
+ executed_in=self.get_current_directory(conversation_id),
283
+ )
284
+
285
+ if not is_safe_command(trimmed):
286
+ return CommandResult(
287
+ success=False,
288
+ stdout="",
289
+ stderr="Command contains forbidden patterns",
290
+ exit_code=1,
291
+ error="Security policy violation",
292
+ executed_in=self.get_current_directory(conversation_id),
293
+ )
294
+
295
+ if is_forbidden_command(trimmed, self._config.forbidden_commands):
296
+ return CommandResult(
297
+ success=False,
298
+ stdout="",
299
+ stderr="Command is forbidden by security policy",
300
+ exit_code=1,
301
+ error="Forbidden command",
302
+ executed_in=self.get_current_directory(conversation_id),
303
+ )
304
+
305
+ cwd = self.get_current_directory(conversation_id)
306
+ use_shell = any(ch in trimmed for ch in [">", "<", "|"])
307
+ try:
308
+ if use_shell:
309
+ cmd_args = ["sh", "-c", trimmed]
310
+ else:
311
+ cmd_args = shlex.split(trimmed)
312
+
313
+ timeout_seconds = self._config.timeout_ms / 1000.0
314
+ proc = await asyncio.wait_for(
315
+ asyncio.create_subprocess_exec(
316
+ *cmd_args,
317
+ stdout=subprocess.PIPE,
318
+ stderr=subprocess.PIPE,
319
+ cwd=cwd,
320
+ ),
321
+ timeout=timeout_seconds,
322
+ )
323
+ stdout_b, stderr_b = await asyncio.wait_for(
324
+ proc.communicate(),
325
+ timeout=timeout_seconds,
326
+ )
327
+ stdout = stdout_b.decode("utf-8", errors="replace")
328
+ stderr = stderr_b.decode("utf-8", errors="replace")
329
+ result = CommandResult(
330
+ success=proc.returncode == 0,
331
+ stdout=stdout,
332
+ stderr=stderr,
333
+ exit_code=proc.returncode,
334
+ executed_in=cwd,
335
+ )
336
+ self._add_history(conversation_id, trimmed, result)
337
+ return result
338
+ except TimeoutError:
339
+ result = CommandResult(
340
+ success=False,
341
+ stdout="",
342
+ stderr="Command timed out",
343
+ exit_code=None,
344
+ error="Command execution timeout",
345
+ executed_in=cwd,
346
+ )
347
+ self._add_history(conversation_id, trimmed, result)
348
+ return result
349
+
350
+ async def git(self, conversation_id: str, args: str) -> CommandResult:
351
+ return await self.execute_shell(conversation_id, f"git {args}")
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Literal
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class CoderConfig:
9
+ enabled: bool
10
+ allowed_directory: str
11
+ timeout_ms: int
12
+ forbidden_commands: list[str]
13
+
14
+
15
+ FileOperationType = Literal["read", "write", "edit", "list", "search"]
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class FileOperation:
20
+ type: FileOperationType
21
+ target: str
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class CommandResult:
26
+ success: bool
27
+ stdout: str
28
+ stderr: str
29
+ exit_code: int | None
30
+ executed_in: str
31
+ error: str | None = None
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class CommandHistoryEntry:
36
+ timestamp: float
37
+ working_directory: str
38
+ command: str
39
+ stdout: str
40
+ stderr: str
41
+ exit_code: int | None
42
+ file_operations: list[FileOperation] | None = None
@@ -0,0 +1,79 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "elizaos-plugin-code"
7
+ version = "2.0.0a4"
8
+ description = "elizaOS Coder Plugin - filesystem + shell + git (restricted)"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.11"
12
+ authors = [{ name = "elizaOS Contributors" }]
13
+ keywords = ["ai", "agents", "coding", "filesystem", "shell", "git", "elizaos"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Typing :: Typed",
22
+ ]
23
+ dependencies = ["pydantic>=2.10.0"]
24
+
25
+ [project.optional-dependencies]
26
+ dev = [
27
+ "pytest>=8.0.0",
28
+ "pytest-asyncio>=0.24.0",
29
+ "pytest-cov>=6.0.0",
30
+ "mypy>=1.14.0",
31
+ "ruff>=0.9.0",
32
+ ]
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/elizaos/eliza"
36
+ Repository = "https://github.com/elizaos/eliza"
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["elizaos_plugin_eliza_coder"]
40
+
41
+ [tool.hatch.build.targets.sdist]
42
+ include = ["/elizaos_plugin_eliza_coder", "/tests", "/README.md"]
43
+
44
+ [tool.pytest.ini_options]
45
+ testpaths = ["tests"]
46
+ python_files = "test_*.py"
47
+ python_functions = "test_*"
48
+ addopts = "-v -p no:anchorpy"
49
+ asyncio_mode = "auto"
50
+ asyncio_default_fixture_loop_scope = "function"
51
+
52
+ [tool.mypy]
53
+ python_version = "3.11"
54
+ strict = true
55
+ warn_return_any = true
56
+ warn_unused_ignores = true
57
+ disallow_untyped_defs = true
58
+ disallow_incomplete_defs = true
59
+ check_untyped_defs = true
60
+ no_implicit_optional = true
61
+
62
+ [tool.ruff]
63
+ target-version = "py311"
64
+ line-length = 100
65
+
66
+ [tool.ruff.lint]
67
+ select = ["E", "W", "F", "I", "B", "C4", "UP", "ANN", "S"]
68
+ ignore = [
69
+ "E501",
70
+ "ANN101",
71
+ "ANN102",
72
+ "S101",
73
+ "S603",
74
+ "S607",
75
+ ]
76
+
77
+ [tool.ruff.lint.isort]
78
+ known-first-party = ["elizaos_plugin_eliza_coder"]
79
+
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from elizaos_plugin_eliza_coder.service import CoderService
9
+
10
+
11
+ @pytest.fixture
12
+ def tmp_allowed_dir(tmp_path: Path) -> Path:
13
+ return tmp_path
14
+
15
+
16
+ @pytest.fixture
17
+ def coder_env(tmp_allowed_dir: Path) -> None:
18
+ os.environ["CODER_ENABLED"] = "true"
19
+ os.environ["CODER_ALLOWED_DIRECTORY"] = str(tmp_allowed_dir)
20
+ os.environ["CODER_TIMEOUT"] = "30000"
21
+ os.environ.pop("CODER_FORBIDDEN_COMMANDS", None)
22
+
23
+
24
+ @pytest.fixture
25
+ def service(coder_env: None) -> CoderService:
26
+ # Reads config from env during init.
27
+ return CoderService()
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ from elizaos_plugin_eliza_coder.actions import (
4
+ ChangeDirectoryAction,
5
+ EditFileAction,
6
+ ExecuteShellAction,
7
+ GitAction,
8
+ ListFilesAction,
9
+ ReadFileAction,
10
+ SearchFilesAction,
11
+ WriteFileAction,
12
+ )
13
+ from elizaos_plugin_eliza_coder.service import CoderService
14
+
15
+
16
+ def _message(text: str) -> dict:
17
+ return {"content": {"text": text}, "room_id": "room-1", "agent_id": "agent-1"}
18
+
19
+
20
+ async def test_write_then_read(service: CoderService) -> None:
21
+ write = WriteFileAction()
22
+ res = await write.handler(_message("write"), {"filepath": "a.txt", "content": "hello"}, service)
23
+ assert res.success
24
+
25
+ read = ReadFileAction()
26
+ res2 = await read.handler(_message('"a.txt"'), {}, service)
27
+ assert res2.success
28
+ assert "hello" in res2.text
29
+
30
+
31
+ async def test_edit_file(service: CoderService) -> None:
32
+ write = WriteFileAction()
33
+ await write.handler(_message("write"), {"filepath": "b.txt", "content": "abc"}, service)
34
+
35
+ edit = EditFileAction()
36
+ res = await edit.handler(
37
+ _message("edit"),
38
+ {"filepath": "b.txt", "old_str": "b", "new_str": "B"},
39
+ service,
40
+ )
41
+ assert res.success
42
+
43
+ read = ReadFileAction()
44
+ res2 = await read.handler(_message('"b.txt"'), {}, service)
45
+ assert "aBc" in res2.text
46
+
47
+
48
+ async def test_list_files(service: CoderService) -> None:
49
+ write = WriteFileAction()
50
+ await write.handler(_message("write"), {"filepath": "c.txt", "content": "x"}, service)
51
+
52
+ ls = ListFilesAction()
53
+ res = await ls.handler(_message("list"), {"path": "."}, service)
54
+ assert res.success
55
+ assert "c.txt" in res.text
56
+
57
+
58
+ async def test_search_files(service: CoderService) -> None:
59
+ write = WriteFileAction()
60
+ await write.handler(_message("write"), {"filepath": "d.txt", "content": "needle here"}, service)
61
+
62
+ search = SearchFilesAction()
63
+ res = await search.handler(
64
+ _message("search"),
65
+ {"pattern": "needle", "path": ".", "max_matches": 50},
66
+ service,
67
+ )
68
+ assert res.success
69
+ assert "d.txt" in res.text
70
+
71
+
72
+ async def test_change_directory_rejects_escape(service: CoderService) -> None:
73
+ cd = ChangeDirectoryAction()
74
+ res = await cd.handler(_message("cd"), {"path": ".."}, service)
75
+ assert not res.success
76
+
77
+
78
+ async def test_execute_shell_pwd(service: CoderService) -> None:
79
+ sh = ExecuteShellAction()
80
+ res = await sh.handler(_message("shell"), {"command": "pwd"}, service)
81
+ assert res.success
82
+
83
+
84
+ async def test_git_requires_repo(service: CoderService) -> None:
85
+ g = GitAction()
86
+ res = await g.handler(_message("git"), {"args": "status"}, service)
87
+ assert not res.success
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ from elizaos_plugin_eliza_coder.providers import CoderStatusProvider
4
+ from elizaos_plugin_eliza_coder.service import CoderService
5
+
6
+
7
+ async def test_coder_status_provider(service: CoderService) -> None:
8
+ provider = CoderStatusProvider()
9
+ result = await provider.get(
10
+ {"room_id": "room-1", "agent_id": "agent-1", "content": {"text": ""}}, {}, service
11
+ )
12
+ assert result.values["allowedDirectory"] == service.allowed_directory