Add persistent conversations and update checks

This commit is contained in:
2026-05-20 07:48:51 +08:00
parent a4b7ef034a
commit 12bed7b6ba
16 changed files with 854 additions and 31 deletions
+2
View File
@@ -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),
]
)
+58
View File
@@ -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,
},
)
+22 -11
View File
@@ -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