Files
2026-07-13 13:22:34 +08:00

273 lines
9.5 KiB
Python

import asyncio
import logging
import os
import shlex
from pathlib import Path
from typing import Any
from mlflow.assistant.config import PermissionsConfig
_logger = logging.getLogger(__name__)
_FILE_TOOLS = {"Read", "Write", "Edit"}
# Restricted mode only permits MLflow CLI and Python; anything else needs Full Access.
_ALLOWED_BASH_COMMANDS = {"mlflow", "python3", "python"}
def _is_path_within(path: Path, root: Path) -> bool:
try:
path.resolve().relative_to(root.resolve())
return True
except ValueError:
return False
def _resolve_file_path(raw_path: str, cwd: Path | None) -> Path:
p = Path(raw_path).expanduser()
if not p.is_absolute() and cwd:
p = cwd / p
return p.resolve()
def static_permission_error(
tool_name: str,
tool_input: dict[str, Any],
perms: PermissionsConfig,
cwd: Path | None,
) -> str | None:
"""Return a denial message if the call is NOT permitted under static (non-full-access)
permissions, or None if it is allowed.
Shared by ``execute_tool`` (to enforce the policy) and the assistant's per-call permission gate
(to decide whether an interactive prompt is even needed): a call the static policy already
allows — e.g. an ``mlflow`` CLI command or an in-workspace file op — runs without prompting,
just as it did before tool-call permissions existed.
"""
if perms.full_access:
return None
if tool_name == "Bash":
command = tool_input.get("command", "").strip()
try:
argv = shlex.split(command)
except ValueError:
return "Permission denied: malformed command"
if not argv or argv[0] not in _ALLOWED_BASH_COMMANDS:
return (
f"Permission denied: only {', '.join(sorted(_ALLOWED_BASH_COMMANDS))} "
"commands are allowed"
)
if tool_name in _FILE_TOOLS and not perms.allow_edit_files:
return f"Permission denied: {tool_name} is not allowed"
if tool_name in {"Write", "Edit"} and not cwd:
return f"Permission denied: {tool_name} requires a configured project directory"
if tool_name in _FILE_TOOLS and cwd:
if raw_path := tool_input.get("file_path") or tool_input.get("path", ""):
target = _resolve_file_path(raw_path, cwd)
if not _is_path_within(target, cwd):
return f"Permission denied: path {raw_path} is outside the workspace {cwd}"
return None
async def execute_tool(
tool_name: str,
tool_input: dict[str, Any],
cwd: Path | None = None,
tracking_uri: str | None = None,
permissions: PermissionsConfig | None = None,
) -> tuple[str, bool]:
perms = permissions or PermissionsConfig()
if (denial := static_permission_error(tool_name, tool_input, perms, cwd)) is not None:
return denial, True
try:
match tool_name:
case "Bash":
return await _execute_bash(tool_input, cwd=cwd, tracking_uri=tracking_uri)
case "Read":
return await asyncio.to_thread(_execute_read, tool_input, cwd=cwd)
case "Write":
return await asyncio.to_thread(_execute_write, tool_input, cwd=cwd)
case "Edit":
return await asyncio.to_thread(_execute_edit, tool_input, cwd=cwd)
case _:
return f"Unknown tool: {tool_name}", True
except Exception as e:
_logger.exception("Tool execution error for %s", tool_name)
return f"Tool execution failed: {e}", True
async def _execute_bash(
tool_input: dict[str, Any],
cwd: Path | None,
tracking_uri: str | None,
) -> tuple[str, bool]:
command = tool_input.get("command", "")
if not command:
return "No command provided", True
env = os.environ.copy()
if tracking_uri:
env["MLFLOW_TRACKING_URI"] = tracking_uri
try:
# Shell required: LLM-generated commands may use pipes, redirects, or && chaining.
proc = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env=env,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=120)
output = stdout.decode("utf-8", errors="replace")
err_output = stderr.decode("utf-8", errors="replace")
if proc.returncode != 0:
result = (
output + err_output if output or err_output else f"Exit code: {proc.returncode}"
)
return result.strip(), True
return (output + err_output).strip() or "(no output)", False
except asyncio.TimeoutError:
return "Command timed out after 120 seconds", True
def _execute_read(tool_input: dict[str, Any], cwd: Path | None = None) -> tuple[str, bool]:
file_path = tool_input.get("file_path") or tool_input.get("path", "")
if not file_path:
return "No file_path provided", True
try:
content = _resolve_file_path(file_path, cwd).read_text(encoding="utf-8")
return content, False
except Exception as e:
return str(e), True
def _execute_write(tool_input: dict[str, Any], cwd: Path | None = None) -> tuple[str, bool]:
file_path = tool_input.get("file_path") or tool_input.get("path", "")
content = tool_input.get("content", "")
if not file_path:
return "No file_path provided", True
try:
p = _resolve_file_path(file_path, cwd)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(content, encoding="utf-8")
return f"Wrote {len(content)} bytes to {file_path}", False
except Exception as e:
return str(e), True
def _execute_edit(tool_input: dict[str, Any], cwd: Path | None = None) -> tuple[str, bool]:
file_path = tool_input.get("file_path") or tool_input.get("path", "")
old_string = tool_input.get("old_string", "")
new_string = tool_input.get("new_string", "")
if not file_path:
return "No file_path provided", True
try:
p = _resolve_file_path(file_path, cwd)
content = p.read_text(encoding="utf-8")
if old_string not in content:
return f"old_string not found in {file_path}", True
new_content = content.replace(old_string, new_string, 1)
p.write_text(new_content, encoding="utf-8")
return f"Edited {file_path}", False
except Exception as e:
return str(e), True
def build_tools_schema() -> list[dict[str, Any]]:
return [
{
"type": "function",
"function": {
"name": "Bash",
"description": (
"Execute a shell command to query or interact with MLflow. "
"Use 'mlflow' CLI commands or Python one-liners with the MLflow SDK."
),
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The shell command to execute.",
}
},
"required": ["command"],
},
},
},
{
"type": "function",
"function": {
"name": "Read",
"description": "Read the contents of a file.",
"parameters": {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Absolute or relative path to the file.",
}
},
"required": ["file_path"],
},
},
},
{
"type": "function",
"function": {
"name": "Write",
"description": "Write content to a file (creates or overwrites).",
"parameters": {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Absolute or relative path to the file.",
},
"content": {
"type": "string",
"description": "Content to write.",
},
},
"required": ["file_path", "content"],
},
},
},
{
"type": "function",
"function": {
"name": "Edit",
"description": (
"Replace the first occurrence of old_string with new_string in a file."
),
"parameters": {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Absolute or relative path to the file.",
},
"old_string": {
"type": "string",
"description": "Exact string to find.",
},
"new_string": {
"type": "string",
"description": "String to replace it with.",
},
},
"required": ["file_path", "old_string", "new_string"],
},
},
},
]