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;
/**
* 内置工具:本地命令执行(跨平台)
*
* 安全边界说明:
*
* - 所有调用在执行前必须经过 ToolGuard 审批(DefaultToolGuard 对 shell 工具默认返回 NEEDS_APPROVAL)
* - 超时控制:默认 60 秒,超时后强制终止进程
* - 输出长度限制:stdout/stderr 各最多 10000 字节,防止大输出撑爆内存
* - 平台适配:Windows 使用 cmd.exe /D /S /C,Linux/macOS 使用 /bin/sh -c。
* 风险已通过 ToolGuard 审批机制控制——每次调用都需要用户明确批准。
* - 输出重定向到临时文件而非管道,确保 timeout 不被管道阻塞失效。
* 参考 MateClaw _execute_subprocess_sync 和 claude-code-haha file-mode 思路。
*
*
* @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;
}
}