Replace repository with DuckLM runtime
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
from typing import Any, Protocol
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ToolResult(BaseModel):
|
||||
ok: bool
|
||||
output: str | None = None
|
||||
error: str | None = None
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class Tool(Protocol):
|
||||
name: str
|
||||
risk_level: str
|
||||
|
||||
async def run(self, args: dict[str, Any]) -> ToolResult:
|
||||
...
|
||||
@@ -0,0 +1,36 @@
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from duck_core.tools.base import ToolResult
|
||||
from duck_core.tools.paths import WorkspacePathError, resolve_workspace_path
|
||||
|
||||
|
||||
class FileReadTool:
|
||||
name = "file_read"
|
||||
risk_level = "low"
|
||||
|
||||
def __init__(self, workspace: str, max_bytes: int = 1_000_000):
|
||||
self.workspace = workspace
|
||||
self.max_bytes = max_bytes
|
||||
|
||||
async def run(self, args: dict[str, Any]) -> ToolResult:
|
||||
raw_path = str(args.get("path", ""))
|
||||
try:
|
||||
path = resolve_workspace_path(self.workspace, raw_path)
|
||||
except WorkspacePathError as exc:
|
||||
return ToolResult(ok=False, error=str(exc))
|
||||
if self._requires_approval(path):
|
||||
return ToolResult(ok=False, error=f"Reading {raw_path} requires explicit approval")
|
||||
if not path.is_file():
|
||||
return ToolResult(ok=False, error=f"File not found: {raw_path}")
|
||||
if path.stat().st_size > self.max_bytes:
|
||||
return ToolResult(ok=False, error=f"File exceeds max size: {self.max_bytes}")
|
||||
return ToolResult(
|
||||
ok=True,
|
||||
output=path.read_text(errors="replace"),
|
||||
metadata={"path": str(path), "bytes_read": path.stat().st_size},
|
||||
)
|
||||
|
||||
def _requires_approval(self, path: Path) -> bool:
|
||||
parts = set(path.parts)
|
||||
return path.name == ".env" or ".ssh" in parts or str(path) == "/etc/shadow"
|
||||
@@ -0,0 +1,40 @@
|
||||
from typing import Any
|
||||
|
||||
from duck_core.tools.base import ToolResult
|
||||
from duck_core.tools.paths import WorkspacePathError, resolve_workspace_path
|
||||
|
||||
|
||||
class FileWriteTool:
|
||||
name = "file_write"
|
||||
risk_level = "medium"
|
||||
|
||||
def __init__(self, workspace: str):
|
||||
self.workspace = workspace
|
||||
|
||||
async def run(self, args: dict[str, Any]) -> ToolResult:
|
||||
raw_path = str(args.get("path", ""))
|
||||
content = str(args.get("content", ""))
|
||||
overwrite = bool(args.get("overwrite", False))
|
||||
try:
|
||||
path = resolve_workspace_path(self.workspace, raw_path)
|
||||
except WorkspacePathError as exc:
|
||||
return ToolResult(ok=False, error=str(exc))
|
||||
if path.exists() and not overwrite:
|
||||
return ToolResult(
|
||||
ok=False,
|
||||
error="Refusing to overwrite existing file without overwrite=true or approval",
|
||||
metadata={"path": str(path)},
|
||||
)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
existed = path.exists()
|
||||
path.write_text(content)
|
||||
return ToolResult(
|
||||
ok=True,
|
||||
output=f"Wrote {raw_path}",
|
||||
metadata={
|
||||
"path": str(path),
|
||||
"bytes_written": len(content.encode()),
|
||||
"created": not existed,
|
||||
"updated": existed,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
from typing import Any
|
||||
|
||||
from duck_core.tools.base import Tool, ToolResult
|
||||
from duck_core.tools.file_read import FileReadTool
|
||||
from duck_core.tools.file_write import FileWriteTool
|
||||
from duck_core.tools.shell_exec_safe import ShellExecSafeTool
|
||||
|
||||
|
||||
class ToolGateway:
|
||||
def __init__(self, tools: list[Tool]):
|
||||
self.tools = {tool.name: tool for tool in tools}
|
||||
|
||||
@classmethod
|
||||
def default(cls, workspace: str) -> "ToolGateway":
|
||||
return cls(
|
||||
[
|
||||
FileReadTool(workspace),
|
||||
FileWriteTool(workspace),
|
||||
ShellExecSafeTool(workspace),
|
||||
]
|
||||
)
|
||||
|
||||
async def run_action(self, action: dict[str, Any]) -> ToolResult:
|
||||
tool_name = str(action.get("tool", ""))
|
||||
tool = self.tools.get(tool_name)
|
||||
if tool is None:
|
||||
return ToolResult(ok=False, error=f"Unknown tool: {tool_name}")
|
||||
args = action.get("args") or {}
|
||||
if not isinstance(args, dict):
|
||||
return ToolResult(ok=False, error="Tool args must be an object")
|
||||
return await tool.run(args)
|
||||
@@ -0,0 +1,13 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class WorkspacePathError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def resolve_workspace_path(workspace: str, relative_path: str) -> Path:
|
||||
root = Path(workspace).resolve()
|
||||
path = (root / relative_path).resolve()
|
||||
if root != path and root not in path.parents:
|
||||
raise WorkspacePathError(f"Path escapes workspace: {relative_path}")
|
||||
return path
|
||||
@@ -0,0 +1,95 @@
|
||||
import shlex
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
from duck_core.tools.base import ToolResult
|
||||
|
||||
|
||||
ALLOWLIST = {
|
||||
"pwd",
|
||||
"ls",
|
||||
"cat",
|
||||
"head",
|
||||
"tail",
|
||||
"grep",
|
||||
"find",
|
||||
"pytest",
|
||||
"python -m pytest",
|
||||
"python3 -m pytest",
|
||||
"git status",
|
||||
"git diff",
|
||||
"git log",
|
||||
}
|
||||
|
||||
BLOCKLIST = {
|
||||
"rm",
|
||||
"sudo",
|
||||
"su",
|
||||
"dd",
|
||||
"mkfs",
|
||||
"mount",
|
||||
"umount",
|
||||
"shutdown",
|
||||
"reboot",
|
||||
"poweroff",
|
||||
"systemctl",
|
||||
"service",
|
||||
"apt install",
|
||||
"apt remove",
|
||||
"pacman -S",
|
||||
"pacman -R",
|
||||
"pip install",
|
||||
"npm install -g",
|
||||
"chmod -R",
|
||||
"chown -R",
|
||||
"curl | sh",
|
||||
"wget | sh",
|
||||
}
|
||||
|
||||
|
||||
class ShellExecSafeTool:
|
||||
name = "shell_exec_safe"
|
||||
risk_level = "medium"
|
||||
|
||||
def __init__(self, workspace: str, timeout_seconds: int = 30):
|
||||
self.workspace = workspace
|
||||
self.timeout_seconds = timeout_seconds
|
||||
|
||||
async def run(self, args: dict[str, Any]) -> ToolResult:
|
||||
command = str(args.get("command", "")).strip()
|
||||
allowed, reason = self._is_allowed(command)
|
||||
if not allowed:
|
||||
return ToolResult(ok=False, error=reason, metadata={"requires_approval": True})
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
cwd=self.workspace,
|
||||
shell=True,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=self.timeout_seconds,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.SubprocessError as exc:
|
||||
return ToolResult(ok=False, error=str(exc))
|
||||
return ToolResult(
|
||||
ok=completed.returncode == 0,
|
||||
output=completed.stdout,
|
||||
error=completed.stderr if completed.returncode else None,
|
||||
metadata={"returncode": completed.returncode, "command": command},
|
||||
)
|
||||
|
||||
def _is_allowed(self, command: str) -> tuple[bool, str | None]:
|
||||
if not command:
|
||||
return False, "Empty command"
|
||||
lowered = command.lower()
|
||||
for blocked in BLOCKLIST:
|
||||
if lowered.startswith(blocked.lower()) or blocked.lower() in lowered:
|
||||
return False, f"Command is blocked: {blocked}"
|
||||
parts = shlex.split(command)
|
||||
prefix1 = parts[0] if parts else ""
|
||||
prefix2 = " ".join(parts[:2])
|
||||
prefix3 = " ".join(parts[:3])
|
||||
if prefix1 in ALLOWLIST or prefix2 in ALLOWLIST or prefix3 in ALLOWLIST:
|
||||
return True, None
|
||||
return False, "Command is outside allowlist and requires approval"
|
||||
Reference in New Issue
Block a user