Feature: Add exec_command MCP tool

Add a one-shot exec_command MCP tool that runs a command on a remote
Windows host and returns stdout plus exit code, reusing the Web terminal
link (main-connection COMMAND_SHELL, a shell sub-connection, and a
sentinel command line located via rfind to tolerate ConPTY echo).

The sentinel marker is embedded in the command line ConPTY echoes back,
so it is only treated as hit when it starts a line (preceded by a newline
or the buffer start); this keeps the echoed marker from being mistaken
for the real sentinel when the echo packet arrives before the output.

Execution is gated at Web remote-desktop sensitivity: a read-only mode
(McpReadonly, default on, hides the tool) and a command whitelist
(McpCmdWhitelist) with built-in read-only prefixes. Shell metacharacters
(& | < > ^) are rejected before whitelist matching, and each execution is
recorded in the server audit log.

Extend the MCP settings dialog with the read-only checkbox and a
multi-line whitelist box (commas and newlines both accepted, normalized
to a comma-separated list on save), and add English and Traditional
Chinese mappings for the new UI and audit-log strings.

Co-Authored-By: deepseek-v4-pro
This commit is contained in:
yuanyuanxiang
2026-08-23 23:23:20 +02:00
parent 8bd6f3b60a
commit 0ddbc1aced
7 changed files with 579 additions and 5 deletions

View File

@@ -4,6 +4,7 @@
#include "HostJson.h" // BuildHostJson单台主机序列化公共函数
#include "context.h" // context 接口
#include "2015RemoteDlg.h" // CMy2015RemoteDlg 成员m_HostList/m_cs/m_ClientMap+ VERSION_STR
#include "LangManager.h" // _TR审计日志标题语言映射
#include <sstream>
@@ -889,6 +890,59 @@ Json::Value BuildListRegistryOutputSchema() {
return schema;
}
// exec_command 的 inputSchemaid 必填、command 必填、timeout_ms 可选)
Json::Value BuildExecCommandInputSchema() {
Json::Value props(Json::objectValue);
Json::Value idProp(Json::objectValue);
idProp["type"] = "string";
idProp["description"] = u8"主机 id取 list_online_hosts / search_hosts 返回的 id 字段";
props["id"] = idProp;
Json::Value cmdProp(Json::objectValue);
cmdProp["type"] = "string";
cmdProp["description"] = u8"要执行的命令(受只读模式与命令白名单约束,仅允许只读命令前缀)";
props["command"] = cmdProp;
Json::Value timeoutProp(Json::objectValue);
timeoutProp["type"] = "integer";
timeoutProp["description"] = u8"等待输出完成的超时毫秒数(可选,默认 20000上限 600000";
props["timeout_ms"] = timeoutProp;
Json::Value schema(Json::objectValue);
schema["type"] = "object";
schema["properties"] = props;
Json::Value required(Json::arrayValue);
required.append("id");
required.append("command");
schema["required"] = required;
return schema;
}
// exec_command 的 outputSchemastdout 文本 / exit_code
Json::Value BuildExecCommandOutputSchema() {
Json::Value props(Json::objectValue);
Json::Value stdoutProp(Json::objectValue);
stdoutProp["type"] = "string";
stdoutProp["description"] = u8"命令输出(已剥哨兵与 ANSI 转义)";
props["stdout"] = stdoutProp;
Json::Value exitProp(Json::objectValue);
exitProp["type"] = "integer";
exitProp["description"] = u8"退出码0=成功、1=非零退出、-1=未知(进程异常退出)";
props["exit_code"] = exitProp;
Json::Value schema(Json::objectValue);
schema["type"] = "object";
schema["properties"] = props;
Json::Value required(Json::arrayValue);
required.append("stdout");
required.append("exit_code");
schema["required"] = required;
return schema;
}
// get_client_log 的 outputSchema原始日志文本
Json::Value BuildClientLogOutputSchema() {
Json::Value props(Json::objectValue);
@@ -1221,6 +1275,19 @@ std::string BuildToolsListResult(const Json::Value& id) {
tools.append(tool);
}
// 13) exec_commandP3一次性远程命令仅 Windows安全门只读默认 + 白名单 + 审计)
// 只读模式下 tools/list 直接隐藏该工具即便被绕过BuildExecCommand 也会再次校验只读。
if (!CMcpServer::Instance().IsReadonly()) {
Json::Value tool(Json::objectValue);
tool["name"] = "exec_command";
tool["description"] = u8"在指定在线 Windows 主机上执行一条命令并返回 stdout 与退出码(受只读模式与命令白名单约束,默认仅允许只读命令前缀,见 McpReadonly / McpCmdWhitelist";
tool["inputSchema"] = BuildExecCommandInputSchema();
tool["outputSchema"] = BuildExecCommandOutputSchema();
tools.append(tool);
}
result["tools"] = tools;
return BuildResult(id, result);
}
@@ -1759,6 +1826,187 @@ std::string BuildListRegistry(const Json::Value& id, const Json::Value& args, CM
return BuildResult(id, result);
}
// ========== P3exec_command ==========
// exec_command 默认白名单:只读命令前缀(逗号分隔,前缀匹配,大小写不敏感)。
// 只放查询/只读类命令写命令del/copy/move/reg add/sc stop/...)一律不在列。
static const char* kDefaultCmdWhitelist =
"dir,type,cd,chdir,ver,hostname,whoami,where,tasklist,systeminfo,ipconfig,netstat,"
"path,set,find,findstr,reg query,sc query,"
"ping,tracert,nslookup,getmac,driverquery,query,gpresult,"
"arp -a,route print,schtasks /query";
// 去首尾空白(空格/制表符/回车/换行)。
static std::string Trim(const std::string& s) {
size_t b = s.find_first_not_of(" \t\r\n");
if (b == std::string::npos) return "";
return s.substr(b, s.find_last_not_of(" \t\r\n") - b + 1);
}
// 去尾部空白(含尾部 CR/LF
static std::string TrimRight(const std::string& s) {
size_t e = s.find_last_not_of(" \t\r\n");
return (e == std::string::npos) ? "" : s.substr(0, e + 1);
}
// 命令白名单校验命令trim + ASCII 小写)以某白名单项为前缀,且其后字符为空/空格/制表符,
// 防止 "dirx" 前缀误配 "dir"。多词前缀sc query / reg query也按完整前缀匹配。
static bool IsCommandAllowed(const std::string& command, const std::string& whitelist) {
std::string c = ToLowerAscii(Trim(command));
if (c.empty()) return false;
size_t start = 0;
while (start < whitelist.size()) {
size_t comma = whitelist.find(',', start);
std::string item = Trim(whitelist.substr(
start, comma == std::string::npos ? std::string::npos : comma - start));
if (!item.empty()) {
std::string p = ToLowerAscii(item);
if (c.size() >= p.size() && c.compare(0, p.size(), p) == 0 &&
(c.size() == p.size() || c[p.size()] == ' ' || c[p.size()] == '\t')) {
return true;
}
}
if (comma == std::string::npos) break;
start = comma + 1;
}
return false;
}
// 剥 ANSI 转义序列CSI/OSC/单字节 ESC避免终端彩色码污染返回文本。
static std::string StripAnsi(const std::string& s) {
std::string out;
out.reserve(s.size());
for (size_t i = 0; i < s.size(); ++i) {
if (s[i] != '\x1b') { out += s[i]; continue; }
++i; // 吞 ESC
if (i < s.size() && s[i] == '[') { // CSI ... 结尾 @~
while (++i < s.size() && !(s[i] >= '@' && s[i] <= '~')) {}
} else if (i < s.size() && s[i] == ']') { // OSC ... 结尾 BEL/ESC
while (++i < s.size() && s[i] != '\x07' && s[i] != '\x1b') {}
}
// 其它单字节 ESC 序列吞掉当前字节即可(循环 ++i 会跳过它)
}
return out;
}
// tools/callexec_commandWindows 一次性远程命令:主连接 COMMAND_SHELL → 子连接终端 →
// 哨兵命令行 → 收集 stdout + exit_code → 关子链接)。安全门:只读默认 + 白名单 + 审计。
std::string BuildExecCommand(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent) {
uint64_t devId = 0;
std::string err;
if (!ParseHostIdArg(args, devId, err))
return BuildError(id, -32602, err);
context* ctx = FindMainContext(parent, devId);
if (!ctx)
return BuildError(id, -32002, "Host not found or offline: " + std::to_string(devId));
// 终端仅 Windows 客户端实现ConPTY / 老 cmd 管道LNX/MAC 远期再考虑。
CString clientType = ctx->GetAdditionalData(RES_CLIENT_TYPE);
if (clientType == "LNX" || clientType == "MAC")
return BuildError(id, -32005, "exec_command is only supported on Windows hosts");
std::string command = Trim(GetStringArg(args, "command"));
if (command.empty())
return BuildError(id, -32602, "Missing required parameter: command");
CMcpServer& mcp = CMcpServer::Instance();
// 安全门 1只读模式直接拒绝默认只读exec_command 需显式关只读才可用)。
if (mcp.IsReadonly())
return BuildError(id, -32006, "exec_command is disabled: MCP is in read-only mode (set McpReadonly=0 to enable)");
// 安全门 2拒绝 shell 元字符,防止 "dir && del ..." 之类绕过白名单的注入。
if (command.find_first_of("&|<>^") != std::string::npos)
return BuildError(id, -32008, "Command contains forbidden shell characters: " + command);
// 安全门 3命令白名单前缀校验。
std::string whitelist = mcp.GetCmdWhitelist();
if (whitelist.empty()) whitelist = kDefaultCmdWhitelist;
if (!IsCommandAllowed(command, whitelist))
return BuildError(id, -32007, "Command not allowed by whitelist: " + command);
int timeoutMs = kMcpToolTimeoutMs;
std::string t = GetStringArg(args, "timeout_ms");
if (!t.empty() && IsDigits(t)) {
int v = atoi(t.c_str());
if (v > 0 && v <= 600000) timeoutMs = v;
}
std::string nonce = GenerateRandomToken().substr(0, 8);
if (!mcp.BeginTermPending(devId, command, nonce))
return BuildError(id, -32003, "Device busy: a terminal session is already active for this host");
// 主连接下发 COMMAND_SHELL客户端建立 shell 子连接并回 TOKEN_*_START。
BYTE cmd = COMMAND_SHELL;
if (!ctx->Send2Client(&cmd, 1)) {
mcp.ClearTermPending(devId);
return BuildError(id, -32004, "Failed to send command to host");
}
context* subCtx = nullptr;
bool isPty = false;
if (!mcp.WaitTerminalReady(devId, subCtx, isPty, timeoutMs))
return BuildError(id, -32001, "Timeout waiting for shell to start");
// 编码ConPTY(UTF-8) / 老 cmd 管道(GBK)。哨兵为纯 ASCII两种编码下字节一致。
UINT cp = isPty ? CP_UTF8 : 936;
// 哨兵命令行:@echo off 仅 ConPTY 需要(抑制回显噪声);老 ShellManager 已自行跳过回显,
// 加 @echo off 反而破坏其回显跳过逻辑。&&/|| 取命令真实退出码(%errorlevel% 在复合句中
// 是解析期展开、已过期,故用控制操作符)。哨兵为随机串,命令输出不可能恰好包含。
std::string line;
if (isPty) line += "@echo off & ";
line += command;
line += " 2>&1 && echo __MCP_DONE_" + nonce + "__0 || echo __MCP_DONE_" + nonce + "__1";
std::string wireLine = ToAnsi(line, cp) + "\r\n";
subCtx->Send2Client((BYTE*)wireLine.data(), (ULONG)wireLine.size());
std::vector<BYTE> raw;
int exitCode = -1;
bool closed = false;
if (!mcp.WaitTerminalDone(devId, raw, exitCode, closed, timeoutMs)) {
subCtx->CancelIO();
return BuildError(id, -32001, "Timeout waiting for command output");
}
subCtx->CancelIO(); // 关子链接,结束 shell 进程
// 清洗raw → UTF-8 → 剥 ANSI → CRLF 归一。raw 已截断到哨兵前。
std::string stdoutStr;
if (!raw.empty()) {
std::string rawStr((const char*)raw.data(), raw.size());
stdoutStr = StripAnsi(ToUtf8(rawStr.c_str(), cp));
size_t p = 0;
while ((p = stdoutStr.find("\r\n", p)) != std::string::npos)
stdoutStr.erase(p, 1);
stdoutStr = TrimRight(stdoutStr);
}
// 审计:命令执行落服务端消息/审计日志(不可关闭)。
if (parent) {
std::string text = "host " + std::to_string(devId) + " exec: " + command;
parent->PostMessageA(WM_SHOWERRORMSG,
(WPARAM)new CString(ToAnsi(text, 936).c_str()),
(LPARAM)new CString(_TR("MCP命令执行"))); // 标题走语言映射GBK随语言切换
}
Json::Value result(Json::objectValue);
Json::Value structuredContent(Json::objectValue);
structuredContent["stdout"] = stdoutStr;
structuredContent["exit_code"] = exitCode;
result["structuredContent"] = structuredContent;
Json::Value content(Json::arrayValue);
Json::Value item(Json::objectValue);
item["type"] = "text";
item["text"] = stdoutStr.empty() ? std::string(u8"(无输出)") : stdoutStr;
content.append(item);
result["content"] = content;
result["isError"] = false;
return BuildResult(id, result);
}
// tools/callget_client_log主连接下发 COMMAND_QUERY_LOG子连接回传 TOKEN_REPORT_LOG
std::string BuildGetClientLog(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent) {
uint64_t devId = 0;
@@ -1873,6 +2121,7 @@ std::string BuildToolsCall(const Json::Value& root, CMy2015RemoteDlg* parent) {
if (toolName == "get_client_log") return BuildGetClientLog(id, args, parent);
if (toolName == "get_audit_log") return BuildGetAuditLog(id, parent);
if (toolName == "list_registry") return BuildListRegistry(id, args, parent);
if (toolName == "exec_command") return BuildExecCommand(id, args, parent);
return BuildError(id, -32602,
"Unknown tool: " + (toolName.empty() ? std::string("(empty)") : toolName));
@@ -1880,6 +2129,158 @@ std::string BuildToolsCall(const Json::Value& root, CMy2015RemoteDlg* parent) {
} // namespace
// ===== P3exec_command 哨兵检测与终端会话 =====
// 在原始 shell 输出字节流中定位哨兵(纯 ASCII编码无关。真实哨兵由 echo 单独输出、独占
// 一行(前面是 \n 或缓冲区开头);而 ConPTY 会把整条命令行回显进输出流,哨兵字样嵌在
// "&& echo ... || echo ..." 里、前面是空格。据此从后往前找「行首命中」,排除回显行误判:
// 否则回显包先于命令输出单独到达时rfind 会命中回显里的 __1 提前结束、截断真实输出。
// 老 ShellManager 已自行跳过回显,输出里只有真实哨兵(仍为行首)。命中 __0 → exit 0__1 → exit 1。
static bool FindSentinel(const std::vector<BYTE>& buf, const std::string& nonce,
size_t& pos, int& exitCode) {
std::string marker = "__MCP_DONE_" + nonce + "__";
if (marker.size() + 1 > buf.size()) return false;
std::string s((const char*)buf.data(), buf.size());
size_t from = std::string::npos;
while (true) {
size_t p = s.rfind(marker, from);
if (p == std::string::npos) return false;
bool lineStart = (p == 0) || (s[p - 1] == '\n');
size_t digitPos = p + marker.size();
if (lineStart && digitPos < s.size() &&
(s[digitPos] == '0' || s[digitPos] == '1')) {
exitCode = (s[digitPos] == '0') ? 0 : 1;
pos = p;
return true;
}
if (p == 0) return false;
from = p - 1;
}
}
bool CMcpServer::IsTermPending(uint64_t device_id) {
std::lock_guard<std::mutex> lk(m_TermMutex);
auto it = m_TermSessions.find(device_id);
return it != m_TermSessions.end() && !it->second.started;
}
void CMcpServer::RegisterTerminalContext(uint64_t device_id, context* subCtx, bool isPty) {
{
std::lock_guard<std::mutex> lk(m_TermMutex);
auto it = m_TermSessions.find(device_id);
if (it == m_TermSessions.end() || it->second.started) return; // 没有等它的会话
TermSession& s = it->second;
s.started = true;
s.subCtx = subCtx;
s.isPty = isPty;
m_TermContextToDevice[subCtx] = device_id;
}
// 关键步骤:告知客户端「启动 shell 输出回流」。客户端读线程靠 COMMAND_NEXT 才启动,
// 漏发会导致 shell 在跑但输出永不送回。PTY 还要先告知初始 80x24否则 TUI 尺寸错乱。
if (isPty) {
BYTE resizeBuf[5];
resizeBuf[0] = CMD_TERMINAL_RESIZE;
*(short*)(resizeBuf + 1) = (short)80;
*(short*)(resizeBuf + 3) = (short)24;
subCtx->Send2Client(resizeBuf, 5);
}
BYTE startCmd = COMMAND_NEXT;
subCtx->Send2Client(&startCmd, 1);
m_TermCv.notify_all();
}
bool CMcpServer::IsTerminalContext(context* subCtx) {
std::lock_guard<std::mutex> lk(m_TermMutex);
return m_TermContextToDevice.find(subCtx) != m_TermContextToDevice.end();
}
void CMcpServer::OnTerminalData(context* subCtx, const BYTE* data, ULONG len) {
std::lock_guard<std::mutex> lk(m_TermMutex);
auto it = m_TermContextToDevice.find(subCtx);
if (it == m_TermContextToDevice.end()) return;
auto sit = m_TermSessions.find(it->second);
if (sit == m_TermSessions.end()) return;
TermSession& s = sit->second;
if (s.done || s.closed) return; // 已结束,忽略迟到数据
s.data.insert(s.data.end(), data, data + len);
if (FindSentinel(s.data, s.nonce, s.sentPos, s.exitCode)) {
s.done = true;
m_TermCv.notify_all();
}
}
void CMcpServer::OnTerminalClosed(context* subCtx) {
std::lock_guard<std::mutex> lk(m_TermMutex);
auto it = m_TermContextToDevice.find(subCtx);
if (it == m_TermContextToDevice.end()) return;
auto sit = m_TermSessions.find(it->second);
if (sit == m_TermSessions.end()) return;
sit->second.closed = true;
m_TermCv.notify_all();
}
bool CMcpServer::BeginTermPending(uint64_t device_id, const std::string& command, const std::string& nonce) {
std::lock_guard<std::mutex> lk(m_TermMutex);
if (m_TermSessions.find(device_id) != m_TermSessions.end()) return false; // 已有会话
TermSession s;
s.command = command;
s.nonce = nonce;
m_TermSessions[device_id] = std::move(s);
return true;
}
bool CMcpServer::WaitTerminalReady(uint64_t device_id, context*& subCtx, bool& isPty, int timeoutMs) {
std::unique_lock<std::mutex> lk(m_TermMutex);
auto it = m_TermSessions.find(device_id);
if (it == m_TermSessions.end()) return false;
bool signaled = m_TermCv.wait_for(lk, std::chrono::milliseconds(timeoutMs),
[&] { return it->second.started; });
if (!signaled) {
m_TermSessions.erase(it); // 超时 → 清理
return false;
}
subCtx = it->second.subCtx;
isPty = it->second.isPty;
return true;
}
bool CMcpServer::WaitTerminalDone(uint64_t device_id, std::vector<BYTE>& out,
int& exitCode, bool& closed, int timeoutMs) {
std::unique_lock<std::mutex> lk(m_TermMutex);
auto it = m_TermSessions.find(device_id);
if (it == m_TermSessions.end()) return false;
bool signaled = m_TermCv.wait_for(lk, std::chrono::milliseconds(timeoutMs),
[&] { return it->second.done || it->second.closed; });
if (!signaled) { // 超时 → 清理(含路由表)
m_TermContextToDevice.erase(it->second.subCtx);
m_TermSessions.erase(it);
return false;
}
TermSession s = it->second; // 拷贝出,避免擦除后悬空
m_TermContextToDevice.erase(s.subCtx);
m_TermSessions.erase(it);
closed = s.closed;
exitCode = s.exitCode;
if (s.done && s.sentPos <= s.data.size())
out.assign(s.data.begin(), s.data.begin() + s.sentPos); // 截断到哨兵前
else
out = s.data; // 进程退出无哨兵:返回已收集的原始输出
return true;
}
void CMcpServer::ClearTermPending(uint64_t device_id) {
std::lock_guard<std::mutex> lk(m_TermMutex);
auto it = m_TermSessions.find(device_id);
if (it != m_TermSessions.end()) m_TermSessions.erase(it);
}
//////////////////////////////////////////////////////////////////////////
// CMcpServer Implementation
//////////////////////////////////////////////////////////////////////////