mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
239 lines
10 KiB
Java
239 lines
10 KiB
Java
package vip.mate.tool.builtin;
|
||
|
||
import cn.hutool.json.JSONObject;
|
||
import cn.hutool.json.JSONUtil;
|
||
import lombok.extern.slf4j.Slf4j;
|
||
import org.springframework.ai.tool.annotation.Tool;
|
||
import org.springframework.ai.tool.annotation.ToolParam;
|
||
import org.springframework.stereotype.Component;
|
||
|
||
import java.io.IOException;
|
||
import java.io.InputStream;
|
||
import java.nio.charset.StandardCharsets;
|
||
import java.nio.file.Files;
|
||
import java.nio.file.Path;
|
||
import java.util.Locale;
|
||
import java.util.concurrent.TimeUnit;
|
||
|
||
/**
|
||
* 内置工具:本地命令执行(跨平台)
|
||
* <p>
|
||
* 安全边界说明:
|
||
* <ul>
|
||
* <li>所有调用在执行前必须经过 ToolGuard 审批(DefaultToolGuard 对 shell 工具默认返回 NEEDS_APPROVAL)</li>
|
||
* <li>超时控制:默认 60 秒,超时后强制终止进程</li>
|
||
* <li>输出长度限制:stdout/stderr 各最多 10000 字节,防止大输出撑爆内存</li>
|
||
* <li>平台适配:Windows 使用 cmd.exe /D /S /C,Linux/macOS 使用 /bin/sh -c。
|
||
* 风险已通过 ToolGuard 审批机制控制——每次调用都需要用户明确批准。</li>
|
||
* <li>输出重定向到临时文件而非管道,确保 timeout 不被管道阻塞失效。
|
||
* 参考 MateClaw _execute_subprocess_sync 和 claude-code-haha file-mode 思路。</li>
|
||
* </ul>
|
||
*
|
||
* @author MateClaw Team
|
||
*/
|
||
@Slf4j
|
||
@Component
|
||
@lombok.RequiredArgsConstructor
|
||
public class ShellExecuteTool {
|
||
|
||
private final vip.mate.i18n.I18nService i18n;
|
||
|
||
private static final int DEFAULT_TIMEOUT_SECONDS = 60;
|
||
private static final int MAX_OUTPUT_BYTES = 10_000;
|
||
private static final boolean IS_WINDOWS = System.getProperty("os.name", "")
|
||
.toLowerCase(Locale.ROOT).contains("win");
|
||
|
||
@Tool(description = "Execute a shell command on the local server. For running system commands, viewing files, running scripts. "
|
||
+ "Uses cmd.exe on Windows, /bin/sh on Linux/macOS. "
|
||
+ "Dangerous operations trigger security approval. Returns structured result with exitCode, stdout, stderr, timedOut.")
|
||
public String execute_shell_command(
|
||
@ToolParam(description = "Shell command to execute") String command,
|
||
@ToolParam(description = "Timeout in seconds, default 60", required = false) Integer timeoutSeconds) {
|
||
|
||
int timeout = (timeoutSeconds != null && timeoutSeconds > 0) ? timeoutSeconds : DEFAULT_TIMEOUT_SECONDS;
|
||
// 硬上限:不允许超过 300 秒
|
||
timeout = Math.min(timeout, 300);
|
||
|
||
log.info("[ShellExecute] Executing command (os={}): {}, timeout={}s",
|
||
IS_WINDOWS ? "windows" : "unix", truncateForLog(command), timeout);
|
||
|
||
JSONObject result = new JSONObject();
|
||
result.set("command", command);
|
||
|
||
Path stdoutFile = null;
|
||
Path stderrFile = null;
|
||
|
||
try {
|
||
// 处理命令中的嵌入换行符(LLM 生成的 JSON 解码后可能包含真实换行)
|
||
// Windows cmd.exe 会在第一个换行处截断命令,Unix sh 也可能误解
|
||
String sanitizedCommand = collapseEmbeddedNewlines(command);
|
||
|
||
ProcessBuilder pb = buildShellProcess(sanitizedCommand);
|
||
// 不继承环境变量中的敏感信息
|
||
pb.environment().keySet().removeIf(key ->
|
||
key.contains("KEY") || key.contains("SECRET") || key.contains("TOKEN")
|
||
|| key.contains("PASSWORD") || key.contains("CREDENTIAL"));
|
||
|
||
// 将 stdout/stderr 重定向到临时文件,而非通过管道读取。
|
||
// 这样 waitFor(timeout) 不会被管道阻塞:
|
||
// 旧方式:readStream(pipe) 阻塞 → waitFor 根本走不到 → timeout 失效
|
||
// 新方式:子进程直接写文件 → waitFor 立即生效 → 超时后读文件取已有输出
|
||
// 同时避免了 Windows 上子进程继承 pipe handle 导致的挂死问题。
|
||
stdoutFile = Files.createTempFile("mc_out_", ".tmp");
|
||
stderrFile = Files.createTempFile("mc_err_", ".tmp");
|
||
pb.redirectOutput(stdoutFile.toFile());
|
||
pb.redirectError(stderrFile.toFile());
|
||
|
||
Process process = pb.start();
|
||
|
||
boolean completed = process.waitFor(timeout, TimeUnit.SECONDS);
|
||
|
||
if (!completed) {
|
||
// 超时:强制终止进程(树)
|
||
killProcessTree(process);
|
||
log.warn("[ShellExecute] Command timed out after {}s: {}", timeout, truncateForLog(command));
|
||
result.set("exitCode", -1);
|
||
result.set("stdout", readFileTruncated(stdoutFile, MAX_OUTPUT_BYTES));
|
||
result.set("stderr", readFileTruncated(stderrFile, MAX_OUTPUT_BYTES));
|
||
result.set("timedOut", true);
|
||
result.set("message", i18n.msg("tool.shell.error.timeout", timeout));
|
||
} else {
|
||
int exitCode = process.exitValue();
|
||
String stdout = readFileTruncated(stdoutFile, MAX_OUTPUT_BYTES);
|
||
String stderr = readFileTruncated(stderrFile, MAX_OUTPUT_BYTES);
|
||
log.info("[ShellExecute] Command completed: exitCode={}, stdout={}chars, stderr={}chars",
|
||
exitCode, stdout.length(), stderr.length());
|
||
result.set("exitCode", exitCode);
|
||
result.set("stdout", stdout);
|
||
result.set("stderr", stderr);
|
||
result.set("timedOut", false);
|
||
}
|
||
|
||
} catch (Exception e) {
|
||
log.error("[ShellExecute] Command execution failed: {}", e.getMessage(), e);
|
||
result.set("exitCode", -1);
|
||
result.set("stdout", "");
|
||
result.set("stderr", i18n.msg("tool.shell.error.exception", e.getMessage()));
|
||
result.set("timedOut", false);
|
||
result.set("error", e.getMessage());
|
||
} finally {
|
||
deleteQuietly(stdoutFile);
|
||
deleteQuietly(stderrFile);
|
||
}
|
||
|
||
return JSONUtil.toJsonPrettyStr(result);
|
||
}
|
||
|
||
/**
|
||
* 根据当前操作系统构建 shell 进程。
|
||
* Windows: cmd.exe /D /S /C "command"
|
||
* /D 禁用 AutoRun 注册表项,避免副作用
|
||
* /S 保留引号原样传递给命令
|
||
* Unix: /bin/sh -c command
|
||
*/
|
||
private static ProcessBuilder buildShellProcess(String command) {
|
||
ProcessBuilder pb;
|
||
if (IS_WINDOWS) {
|
||
String winCommand = sanitizeWindowsCommand(command);
|
||
pb = new ProcessBuilder("cmd.exe", "/D", "/S", "/C", winCommand);
|
||
} else {
|
||
pb = new ProcessBuilder("/bin/sh", "-c", command);
|
||
}
|
||
|
||
// 设置工作区活动目录
|
||
java.nio.file.Path workingDir = vip.mate.tool.guard.WorkspacePathGuard.getWorkingDirectory();
|
||
if (workingDir != null && java.nio.file.Files.isDirectory(workingDir)) {
|
||
pb.directory(workingDir.toFile());
|
||
log.info("[ShellExecute] Working directory set to: {}", workingDir);
|
||
}
|
||
|
||
return pb;
|
||
}
|
||
|
||
/**
|
||
* 将命令中的嵌入换行符替换为空格。
|
||
* LLM 在 JSON tool_call 中产生的 \n 解码后变成真实换行,
|
||
* 在 Windows cmd.exe 中会导致命令被截断,在 Unix sh 中可能被误解为命令分隔符。
|
||
*/
|
||
private static String collapseEmbeddedNewlines(String command) {
|
||
if (command == null || !command.contains("\n")) {
|
||
return command;
|
||
}
|
||
return command.replace("\r\n", " ").replace("\n", " ");
|
||
}
|
||
|
||
/**
|
||
* 修复 LLM 常见的 Windows 命令转义问题。
|
||
* LLM 有时会产生 bash 风格的反斜杠转义引号 (\"),
|
||
* 如果命令中所有双引号都被反斜杠转义,则认为是 JSON/bash 伪影并去除反斜杠。
|
||
*/
|
||
private static String sanitizeWindowsCommand(String command) {
|
||
if (command.contains("\\\"") && !command.replace("\\\"", "").contains("\"")) {
|
||
return command.replace("\\\"", "\"");
|
||
}
|
||
return command;
|
||
}
|
||
|
||
/**
|
||
* 尽力终止进程树。
|
||
* Windows: 使用 taskkill /F /T 终止整个进程树(包括子进程)。
|
||
* Unix: destroyForcibly() 发送 SIGKILL,对于 /bin/sh 启动的子进程基本够用。
|
||
* 注意:Windows 上如果 taskkill 失败,仍回退到 destroyForcibly(),
|
||
* 极端情况下可能有子进程残留(如后台 detached 进程)。
|
||
*/
|
||
private static void killProcessTree(Process process) {
|
||
if (IS_WINDOWS) {
|
||
try {
|
||
new ProcessBuilder("taskkill", "/F", "/T", "/PID", String.valueOf(process.pid()))
|
||
.redirectErrorStream(true)
|
||
.start()
|
||
.waitFor(10, TimeUnit.SECONDS);
|
||
} catch (Exception e) {
|
||
process.destroyForcibly();
|
||
}
|
||
} else {
|
||
process.destroyForcibly();
|
||
}
|
||
try {
|
||
process.waitFor(5, TimeUnit.SECONDS);
|
||
} catch (InterruptedException e) {
|
||
Thread.currentThread().interrupt();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 从临时文件中读取输出,截断到 maxBytes 字节。
|
||
* 进程退出或被杀死后调用,读取子进程已写入文件的内容。
|
||
*/
|
||
private static String readFileTruncated(Path file, int maxBytes) {
|
||
try {
|
||
if (file == null || !Files.exists(file)) return "";
|
||
long size = Files.size(file);
|
||
if (size == 0) return "";
|
||
|
||
boolean truncated = size > maxBytes;
|
||
try (InputStream is = Files.newInputStream(file)) {
|
||
byte[] data = is.readNBytes(maxBytes);
|
||
String content = new String(data, StandardCharsets.UTF_8);
|
||
if (truncated) {
|
||
content += "\n... [output truncated, exceeds " + maxBytes + " byte limit]";
|
||
}
|
||
return content;
|
||
}
|
||
} catch (IOException e) {
|
||
return "[read output failed: " + e.getMessage() + "]";
|
||
}
|
||
}
|
||
|
||
private static void deleteQuietly(Path file) {
|
||
if (file != null) {
|
||
try { Files.deleteIfExists(file); } catch (IOException ignored) {}
|
||
}
|
||
}
|
||
|
||
private String truncateForLog(String text) {
|
||
if (text == null) return "null";
|
||
return text.length() > 200 ? text.substring(0, 200) + "..." : text;
|
||
}
|
||
}
|