Add persistent conversations and update checks
This commit is contained in:
@@ -4,6 +4,7 @@ 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.list_dir import ListDirTool
|
||||
from duck_core.tools.os_update_check import OsUpdateCheckTool
|
||||
from duck_core.tools.search_files import SearchFilesTool
|
||||
from duck_core.tools.shell_exec_safe import ShellExecSafeTool
|
||||
|
||||
@@ -20,6 +21,7 @@ class ToolGateway:
|
||||
FileWriteTool(workspace),
|
||||
ListDirTool(workspace),
|
||||
SearchFilesTool(workspace),
|
||||
OsUpdateCheckTool(workspace),
|
||||
ShellExecSafeTool(workspace),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
from duck_core.tools.base import ToolResult
|
||||
|
||||
|
||||
class OsUpdateCheckTool:
|
||||
name = "os_update_check"
|
||||
risk_level = "low"
|
||||
|
||||
def __init__(self, workspace: str, timeout_seconds: int = 60, max_lines: int = 300):
|
||||
self.workspace = workspace
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self.max_lines = max_lines
|
||||
|
||||
async def run(self, args: dict[str, Any]) -> ToolResult:
|
||||
if shutil.which("apt"):
|
||||
return self._run_apt()
|
||||
return ToolResult(
|
||||
ok=False,
|
||||
error="No supported package manager found for update checks.",
|
||||
metadata={"supported_package_managers": ["apt"]},
|
||||
)
|
||||
|
||||
def _run_apt(self) -> ToolResult:
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
["apt", "list", "--upgradable"],
|
||||
cwd=self.workspace,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=self.timeout_seconds,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.SubprocessError as exc:
|
||||
return ToolResult(ok=False, error=str(exc), metadata={"package_manager": "apt"})
|
||||
|
||||
lines = completed.stdout.splitlines()
|
||||
package_lines = [line for line in lines if "/" in line and "upgradable from:" in line]
|
||||
output_lines = lines[: self.max_lines]
|
||||
truncated = len(lines) > self.max_lines
|
||||
if truncated:
|
||||
output_lines.append(f"... truncated after {self.max_lines} lines")
|
||||
|
||||
return ToolResult(
|
||||
ok=completed.returncode == 0,
|
||||
output="\n".join(output_lines),
|
||||
error=completed.stderr or None,
|
||||
metadata={
|
||||
"package_manager": "apt",
|
||||
"command": "apt list --upgradable",
|
||||
"returncode": completed.returncode,
|
||||
"upgradable_count": len(package_lines),
|
||||
"refreshed_cache": False,
|
||||
"truncated": truncated,
|
||||
},
|
||||
)
|
||||
@@ -58,9 +58,10 @@ class ShellExecSafeTool:
|
||||
async def run(self, args: dict[str, Any]) -> ToolResult:
|
||||
command = str(args.get("command", "")).strip()
|
||||
approved = bool(args.get("_approved"))
|
||||
allowed, reason = self._is_allowed(command, approved=approved)
|
||||
allowed, reason, blocked = self._is_allowed(command, approved=approved)
|
||||
if not allowed:
|
||||
return ToolResult(ok=False, error=reason, metadata={"requires_approval": True})
|
||||
metadata = {"blocked": True} if blocked else {"requires_approval": True}
|
||||
return ToolResult(ok=False, error=reason, metadata=metadata)
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
@@ -80,19 +81,29 @@ class ShellExecSafeTool:
|
||||
metadata={"returncode": completed.returncode, "command": command},
|
||||
)
|
||||
|
||||
def _is_allowed(self, command: str, approved: bool = False) -> tuple[bool, str | None]:
|
||||
def _is_allowed(
|
||||
self, command: str, approved: bool = False
|
||||
) -> tuple[bool, str | None, bool]:
|
||||
if not command:
|
||||
return False, "Empty command"
|
||||
return False, "Empty command", False
|
||||
lowered = command.lower()
|
||||
for blocked in BLOCKLIST:
|
||||
if lowered.startswith(blocked.lower()) or blocked.lower() in lowered:
|
||||
return False, f"Command is blocked: {blocked}"
|
||||
if approved:
|
||||
return True, None
|
||||
parts = shlex.split(command)
|
||||
for blocked in BLOCKLIST:
|
||||
if self._matches_blocked_command(lowered, parts, blocked):
|
||||
return False, f"Command is blocked: {blocked}", True
|
||||
if approved:
|
||||
return True, None, False
|
||||
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"
|
||||
return True, None, False
|
||||
return False, "Command is outside allowlist and requires approval", False
|
||||
|
||||
def _matches_blocked_command(
|
||||
self, lowered_command: str, parts: list[str], blocked: str
|
||||
) -> bool:
|
||||
lowered_blocked = blocked.lower()
|
||||
if " " in lowered_blocked or "|" in lowered_blocked:
|
||||
return lowered_command.startswith(lowered_blocked) or lowered_blocked in lowered_command
|
||||
return bool(parts) and parts[0].lower() == lowered_blocked
|
||||
|
||||
Reference in New Issue
Block a user