mateclaw/mateclaw-server/src/main/java/vip/mate/tool/builtin/ShellExecuteTool.java

239 lines
10 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 /CLinux/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;
}
}