feat: Improve execute terminal commands tool

This commit is contained in:
Petr Mironychev
2025-11-27 01:12:21 +01:00
parent 85a7bba90e
commit 9b0ae98f02
7 changed files with 378 additions and 70 deletions

View File

@ -106,11 +106,15 @@ const char CA_ENABLE_CHAT_TOOLS[] = "QodeAssist.caEnableChatTools";
const char CA_USE_TOOLS[] = "QodeAssist.caUseTools"; const char CA_USE_TOOLS[] = "QodeAssist.caUseTools";
const char CA_ALLOW_FILE_SYSTEM_READ[] = "QodeAssist.caAllowFileSystemRead"; const char CA_ALLOW_FILE_SYSTEM_READ[] = "QodeAssist.caAllowFileSystemRead";
const char CA_ALLOW_FILE_SYSTEM_WRITE[] = "QodeAssist.caAllowFileSystemWrite"; const char CA_ALLOW_FILE_SYSTEM_WRITE[] = "QodeAssist.caAllowFileSystemWrite";
const char CA_ALLOW_NETWORK_ACCESS[] = "QodeAssist.caAllowNetworkAccess";
const char CA_ALLOW_ACCESS_OUTSIDE_PROJECT[] = "QodeAssist.caAllowAccessOutsideProject"; const char CA_ALLOW_ACCESS_OUTSIDE_PROJECT[] = "QodeAssist.caAllowAccessOutsideProject";
const char CA_ENABLE_EDIT_FILE_TOOL[] = "QodeAssist.caEnableEditFileTool"; const char CA_ENABLE_EDIT_FILE_TOOL[] = "QodeAssist.caEnableEditFileTool";
const char CA_ENABLE_BUILD_PROJECT_TOOL[] = "QodeAssist.caEnableBuildProjectTool"; const char CA_ENABLE_BUILD_PROJECT_TOOL[] = "QodeAssist.caEnableBuildProjectTool";
const char CA_ENABLE_TERMINAL_COMMAND_TOOL[] = "QodeAssist.caEnableTerminalCommandTool"; const char CA_ENABLE_TERMINAL_COMMAND_TOOL[] = "QodeAssist.caEnableTerminalCommandTool";
const char CA_ALLOWED_TERMINAL_COMMANDS[] = "QodeAssist.caAllowedTerminalCommands"; const char CA_ALLOWED_TERMINAL_COMMANDS[] = "QodeAssist.caAllowedTerminalCommands";
const char CA_ALLOWED_TERMINAL_COMMANDS_LINUX[] = "QodeAssist.caAllowedTerminalCommandsLinux";
const char CA_ALLOWED_TERMINAL_COMMANDS_MACOS[] = "QodeAssist.caAllowedTerminalCommandsMacOS";
const char CA_ALLOWED_TERMINAL_COMMANDS_WINDOWS[] = "QodeAssist.caAllowedTerminalCommandsWindows";
const char QODE_ASSIST_GENERAL_OPTIONS_ID[] = "QodeAssist.GeneralOptions"; const char QODE_ASSIST_GENERAL_OPTIONS_ID[] = "QodeAssist.GeneralOptions";
const char QODE_ASSIST_GENERAL_SETTINGS_PAGE_ID[] = "QodeAssist.1GeneralSettingsPageId"; const char QODE_ASSIST_GENERAL_SETTINGS_PAGE_ID[] = "QodeAssist.1GeneralSettingsPageId";

View File

@ -54,6 +54,13 @@ ToolsSettings::ToolsSettings()
Tr::tr("Allow tools to write and modify files on disk (WARNING: Use with caution!)")); Tr::tr("Allow tools to write and modify files on disk (WARNING: Use with caution!)"));
allowFileSystemWrite.setDefaultValue(false); allowFileSystemWrite.setDefaultValue(false);
allowNetworkAccess.setSettingsKey(Constants::CA_ALLOW_NETWORK_ACCESS);
allowNetworkAccess.setLabelText(Tr::tr("Allow Network Access for tools"));
allowNetworkAccess.setToolTip(
Tr::tr("Allow tools to make network requests (e.g., execute commands like git, curl, wget). "
"Required for ExecuteTerminalCommandTool with network-capable commands."));
allowNetworkAccess.setDefaultValue(false);
allowAccessOutsideProject.setSettingsKey(Constants::CA_ALLOW_ACCESS_OUTSIDE_PROJECT); allowAccessOutsideProject.setSettingsKey(Constants::CA_ALLOW_ACCESS_OUTSIDE_PROJECT);
allowAccessOutsideProject.setLabelText(Tr::tr("Allow file access outside project")); allowAccessOutsideProject.setLabelText(Tr::tr("Allow file access outside project"));
allowAccessOutsideProject.setToolTip( allowAccessOutsideProject.setToolTip(
@ -90,13 +97,29 @@ ToolsSettings::ToolsSettings()
"unexpected behavior.")); "unexpected behavior."));
enableTerminalCommandTool.setDefaultValue(false); enableTerminalCommandTool.setDefaultValue(false);
allowedTerminalCommands.setSettingsKey(Constants::CA_ALLOWED_TERMINAL_COMMANDS); allowedTerminalCommandsLinux.setSettingsKey(Constants::CA_ALLOWED_TERMINAL_COMMANDS_LINUX);
allowedTerminalCommands.setLabelText(Tr::tr("Allowed Terminal Commands")); allowedTerminalCommandsLinux.setLabelText(Tr::tr("Allowed Commands (Linux)"));
allowedTerminalCommands.setToolTip( allowedTerminalCommandsLinux.setToolTip(
Tr::tr("Comma-separated list of terminal commands that AI is allowed to execute. " Tr::tr("Comma-separated list of terminal commands that AI is allowed to execute on Linux. "
"Example: git, ls, cat, grep, cmake")); "Example: git, ls, cat, grep, find, cmake"));
allowedTerminalCommands.setDisplayStyle(Utils::StringAspect::LineEditDisplay); allowedTerminalCommandsLinux.setDisplayStyle(Utils::StringAspect::LineEditDisplay);
allowedTerminalCommands.setDefaultValue("git, ls, cat, grep"); allowedTerminalCommandsLinux.setDefaultValue("git, ls, cat, grep, find");
allowedTerminalCommandsMacOS.setSettingsKey(Constants::CA_ALLOWED_TERMINAL_COMMANDS_MACOS);
allowedTerminalCommandsMacOS.setLabelText(Tr::tr("Allowed Commands (macOS)"));
allowedTerminalCommandsMacOS.setToolTip(
Tr::tr("Comma-separated list of terminal commands that AI is allowed to execute on macOS. "
"Example: git, ls, cat, grep, find, cmake"));
allowedTerminalCommandsMacOS.setDisplayStyle(Utils::StringAspect::LineEditDisplay);
allowedTerminalCommandsMacOS.setDefaultValue("git, ls, cat, grep, find");
allowedTerminalCommandsWindows.setSettingsKey(Constants::CA_ALLOWED_TERMINAL_COMMANDS_WINDOWS);
allowedTerminalCommandsWindows.setLabelText(Tr::tr("Allowed Commands (Windows)"));
allowedTerminalCommandsWindows.setToolTip(
Tr::tr("Comma-separated list of terminal commands that AI is allowed to execute on Windows. "
"Example: git, dir, type, findstr, where, cmake"));
allowedTerminalCommandsWindows.setDisplayStyle(Utils::StringAspect::LineEditDisplay);
allowedTerminalCommandsWindows.setDefaultValue("git, dir, type, findstr, where");
resetToDefaults.m_buttonText = Tr::tr("Reset Page to Defaults"); resetToDefaults.m_buttonText = Tr::tr("Reset Page to Defaults");
@ -107,6 +130,16 @@ ToolsSettings::ToolsSettings()
setLayouter([this]() { setLayouter([this]() {
using namespace Layouting; using namespace Layouting;
#ifdef Q_OS_LINUX
auto &currentOsCommands = allowedTerminalCommandsLinux;
#elif defined(Q_OS_MACOS)
auto &currentOsCommands = allowedTerminalCommandsMacOS;
#elif defined(Q_OS_WIN)
auto &currentOsCommands = allowedTerminalCommandsWindows;
#else
auto &currentOsCommands = allowedTerminalCommandsLinux; // fallback
#endif
return Column{ return Column{
Row{Stretch{1}, resetToDefaults}, Row{Stretch{1}, resetToDefaults},
Space{8}, Space{8},
@ -115,6 +148,7 @@ ToolsSettings::ToolsSettings()
Column{ Column{
allowFileSystemRead, allowFileSystemRead,
allowFileSystemWrite, allowFileSystemWrite,
allowNetworkAccess,
allowAccessOutsideProject allowAccessOutsideProject
}}, }},
Space{8}, Space{8},
@ -124,7 +158,7 @@ ToolsSettings::ToolsSettings()
enableEditFileTool, enableEditFileTool,
enableBuildProjectTool, enableBuildProjectTool,
enableTerminalCommandTool, enableTerminalCommandTool,
allowedTerminalCommands, currentOsCommands,
autoApplyFileEdits}}, autoApplyFileEdits}},
Stretch{1}}; Stretch{1}};
}); });
@ -151,12 +185,15 @@ void ToolsSettings::resetSettingsToDefaults()
if (reply == QMessageBox::Yes) { if (reply == QMessageBox::Yes) {
resetAspect(allowFileSystemRead); resetAspect(allowFileSystemRead);
resetAspect(allowFileSystemWrite); resetAspect(allowFileSystemWrite);
resetAspect(allowNetworkAccess);
resetAspect(allowAccessOutsideProject); resetAspect(allowAccessOutsideProject);
resetAspect(autoApplyFileEdits); resetAspect(autoApplyFileEdits);
resetAspect(enableEditFileTool); resetAspect(enableEditFileTool);
resetAspect(enableBuildProjectTool); resetAspect(enableBuildProjectTool);
resetAspect(enableTerminalCommandTool); resetAspect(enableTerminalCommandTool);
resetAspect(allowedTerminalCommands); resetAspect(allowedTerminalCommandsLinux);
resetAspect(allowedTerminalCommandsMacOS);
resetAspect(allowedTerminalCommandsWindows);
writeSettings(); writeSettings();
} }
} }

View File

@ -34,13 +34,16 @@ public:
Utils::BoolAspect allowFileSystemRead{this}; Utils::BoolAspect allowFileSystemRead{this};
Utils::BoolAspect allowFileSystemWrite{this}; Utils::BoolAspect allowFileSystemWrite{this};
Utils::BoolAspect allowNetworkAccess{this};
Utils::BoolAspect allowAccessOutsideProject{this}; Utils::BoolAspect allowAccessOutsideProject{this};
// Experimental features // Experimental features
Utils::BoolAspect enableEditFileTool{this}; Utils::BoolAspect enableEditFileTool{this};
Utils::BoolAspect enableBuildProjectTool{this}; Utils::BoolAspect enableBuildProjectTool{this};
Utils::BoolAspect enableTerminalCommandTool{this}; Utils::BoolAspect enableTerminalCommandTool{this};
Utils::StringAspect allowedTerminalCommands{this}; Utils::StringAspect allowedTerminalCommandsLinux{this};
Utils::StringAspect allowedTerminalCommandsMacOS{this};
Utils::StringAspect allowedTerminalCommandsWindows{this};
Utils::BoolAspect autoApplyFileEdits{this}; Utils::BoolAspect autoApplyFileEdits{this};
private: private:

View File

@ -112,7 +112,8 @@ QJsonObject BuildProjectTool::getDefinition(LLMCore::ToolSchemaFormat format) co
LLMCore::ToolPermissions BuildProjectTool::requiredPermissions() const LLMCore::ToolPermissions BuildProjectTool::requiredPermissions() const
{ {
return LLMCore::ToolPermission::None; return LLMCore::ToolPermission::FileSystemRead
| LLMCore::ToolPermission::FileSystemWrite;
} }
QFuture<QString> BuildProjectTool::executeAsync(const QJsonObject &input) QFuture<QString> BuildProjectTool::executeAsync(const QJsonObject &input)

View File

@ -30,6 +30,7 @@
#include <QPromise> #include <QPromise>
#include <QRegularExpression> #include <QRegularExpression>
#include <QSharedPointer> #include <QSharedPointer>
#include <QTimer>
namespace QodeAssist::Tools { namespace QodeAssist::Tools {
@ -50,16 +51,7 @@ QString ExecuteTerminalCommandTool::stringName() const
QString ExecuteTerminalCommandTool::description() const QString ExecuteTerminalCommandTool::description() const
{ {
const QStringList allowed = getAllowedCommands(); return getCommandDescription();
const QString allowedList = allowed.isEmpty() ? "none" : allowed.join(", ");
return QString(
"Execute a terminal command in the project directory. "
"Only commands from the allowed list can be executed. "
"Currently allowed commands: %1. "
"The command will be executed in the root directory of the active project. "
"Returns the command output (stdout and stderr) or an error message if the command fails.")
.arg(allowedList);
} }
QJsonObject ExecuteTerminalCommandTool::getDefinition(LLMCore::ToolSchemaFormat format) const QJsonObject ExecuteTerminalCommandTool::getDefinition(LLMCore::ToolSchemaFormat format) const
@ -67,19 +59,18 @@ QJsonObject ExecuteTerminalCommandTool::getDefinition(LLMCore::ToolSchemaFormat
QJsonObject definition; QJsonObject definition;
definition["type"] = "object"; definition["type"] = "object";
const QStringList allowed = getAllowedCommands(); const QString commandDesc = getCommandDescription();
const QString allowedList = allowed.isEmpty() ? "none" : allowed.join(", ");
QJsonObject properties; QJsonObject properties;
properties["command"] = QJsonObject{ properties["command"] = QJsonObject{
{"type", "string"}, {"type", "string"},
{"description", {"description", commandDesc}};
QString("The terminal command to execute. Must be one of the allowed commands: %1")
.arg(allowedList)}};
properties["args"] = QJsonObject{ properties["args"] = QJsonObject{
{"type", "string"}, {"type", "string"},
{"description", "Optional arguments for the command (default: empty)"}}; {"description",
"Optional arguments for the command. Arguments with spaces should be properly quoted. "
"Example: '--file \"path with spaces.txt\" --verbose'"}};
definition["properties"] = properties; definition["properties"] = properties;
definition["required"] = QJsonArray{"command"}; definition["required"] = QJsonArray{"command"};
@ -100,7 +91,9 @@ QJsonObject ExecuteTerminalCommandTool::getDefinition(LLMCore::ToolSchemaFormat
LLMCore::ToolPermissions ExecuteTerminalCommandTool::requiredPermissions() const LLMCore::ToolPermissions ExecuteTerminalCommandTool::requiredPermissions() const
{ {
return LLMCore::ToolPermission::None; return LLMCore::ToolPermission::FileSystemRead
| LLMCore::ToolPermission::FileSystemWrite
| LLMCore::ToolPermission::NetworkAccess;
} }
QFuture<QString> ExecuteTerminalCommandTool::executeAsync(const QJsonObject &input) QFuture<QString> ExecuteTerminalCommandTool::executeAsync(const QJsonObject &input)
@ -113,6 +106,22 @@ QFuture<QString> ExecuteTerminalCommandTool::executeAsync(const QJsonObject &inp
return QtFuture::makeReadyFuture(QString("Error: Command parameter is required.")); return QtFuture::makeReadyFuture(QString("Error: Command parameter is required."));
} }
if (command.length() > MAX_COMMAND_LENGTH) {
LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Command too long (%1 chars)")
.arg(command.length()));
return QtFuture::makeReadyFuture(
QString("Error: Command exceeds maximum length of %1 characters.")
.arg(MAX_COMMAND_LENGTH));
}
if (args.length() > MAX_ARGS_LENGTH) {
LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Arguments too long (%1 chars)")
.arg(args.length()));
return QtFuture::makeReadyFuture(
QString("Error: Arguments exceed maximum length of %1 characters.")
.arg(MAX_ARGS_LENGTH));
}
if (!isCommandAllowed(command)) { if (!isCommandAllowed(command)) {
LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Command '%1' is not allowed") LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Command '%1' is not allowed")
.arg(command)); .arg(command));
@ -124,6 +133,23 @@ QFuture<QString> ExecuteTerminalCommandTool::executeAsync(const QJsonObject &inp
.arg(allowedList)); .arg(allowedList));
} }
if (!isCommandSafe(command)) {
LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Command '%1' contains unsafe characters")
.arg(command));
return QtFuture::makeReadyFuture(
QString("Error: Command '%1' contains potentially dangerous characters. "
"Only alphanumeric characters, hyphens, underscores, and dots are allowed.")
.arg(command));
}
if (!args.isEmpty() && !areArgumentsSafe(args)) {
LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Arguments contain unsafe patterns: '%1'")
.arg(args));
return QtFuture::makeReadyFuture(
QString("Error: Arguments contain potentially dangerous patterns (command chaining, "
"redirection, or pipe operators)."));
}
auto *project = ProjectExplorer::ProjectManager::startupProject(); auto *project = ProjectExplorer::ProjectManager::startupProject();
QString workingDir; QString workingDir;
@ -136,9 +162,20 @@ QFuture<QString> ExecuteTerminalCommandTool::executeAsync(const QJsonObject &inp
workingDir = QDir::currentPath(); workingDir = QDir::currentPath();
} }
LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Executing command '%1' with args '%2'") QDir dir(workingDir);
if (!dir.exists() || !dir.isReadable()) {
LOG_MESSAGE(
QString("ExecuteTerminalCommandTool: Working directory '%1' is not accessible")
.arg(workingDir));
return QtFuture::makeReadyFuture(
QString("Error: Working directory '%1' does not exist or is not accessible.")
.arg(workingDir));
}
LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Executing command '%1' with args '%2' in '%3'")
.arg(command) .arg(command)
.arg(args)); .arg(args.isEmpty() ? "(no args)" : args)
.arg(workingDir));
auto promise = QSharedPointer<QPromise<QString>>::create(); auto promise = QSharedPointer<QPromise<QString>>::create();
QFuture<QString> future = promise->future(); QFuture<QString> future = promise->future();
@ -147,40 +184,85 @@ QFuture<QString> ExecuteTerminalCommandTool::executeAsync(const QJsonObject &inp
QProcess *process = new QProcess(); QProcess *process = new QProcess();
process->setWorkingDirectory(workingDir); process->setWorkingDirectory(workingDir);
process->setProcessChannelMode(QProcess::MergedChannels); process->setProcessChannelMode(QProcess::MergedChannels);
process->setReadChannel(QProcess::StandardOutput);
QTimer *timeoutTimer = new QTimer();
timeoutTimer->setSingleShot(true);
timeoutTimer->setInterval(COMMAND_TIMEOUT_MS);
auto outputSize = QSharedPointer<qint64>::create(0);
QObject::connect(timeoutTimer, &QTimer::timeout, [process, promise, command, args, timeoutTimer]() {
LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Command '%1 %2' timed out after %3ms")
.arg(command)
.arg(args)
.arg(COMMAND_TIMEOUT_MS));
process->terminate();
QTimer::singleShot(1000, process, [process]() {
if (process->state() == QProcess::Running) {
LOG_MESSAGE("ExecuteTerminalCommandTool: Forcefully killing process after timeout");
process->kill();
}
});
promise->addResult(QString("Error: Command '%1 %2' timed out after %3 seconds. "
"The process has been terminated.")
.arg(command)
.arg(args.isEmpty() ? "" : args)
.arg(COMMAND_TIMEOUT_MS / 1000));
promise->finish();
process->deleteLater();
timeoutTimer->deleteLater();
});
QObject::connect( QObject::connect(
process, process,
QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished), QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
[process, promise, command, args](int exitCode, QProcess::ExitStatus exitStatus) { [this, process, promise, command, args, timeoutTimer, outputSize](
const QString output = QString::fromUtf8(process->readAll()); int exitCode, QProcess::ExitStatus exitStatus) {
timeoutTimer->stop();
timeoutTimer->deleteLater();
const QByteArray rawOutput = process->readAll();
*outputSize += rawOutput.size();
const QString output = sanitizeOutput(QString::fromUtf8(rawOutput), *outputSize);
const QString fullCommand = args.isEmpty() ? command : QString("%1 %2").arg(command).arg(args);
if (exitStatus == QProcess::NormalExit) { if (exitStatus == QProcess::NormalExit) {
if (exitCode == 0) { if (exitCode == 0) {
LOG_MESSAGE( LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Command '%1' completed "
QString("ExecuteTerminalCommandTool: Command completed successfully")); "successfully (output size: %2 bytes)")
.arg(fullCommand)
.arg(*outputSize));
promise->addResult( promise->addResult(
QString("Command '%1 %2' executed successfully.\n\nOutput:\n%3") QString("Command '%1' executed successfully.\n\nOutput:\n%2")
.arg(command) .arg(fullCommand)
.arg(args)
.arg(output.isEmpty() ? "(no output)" : output)); .arg(output.isEmpty() ? "(no output)" : output));
} else { } else {
LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Command failed with exit " LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Command '%1' failed with "
"code %1") "exit code %2 (output size: %3 bytes)")
.arg(exitCode)); .arg(fullCommand)
.arg(exitCode)
.arg(*outputSize));
promise->addResult( promise->addResult(
QString("Command '%1 %2' failed with exit code %3.\n\nOutput:\n%4") QString("Command '%1' failed with exit code %2.\n\nOutput:\n%3")
.arg(command) .arg(fullCommand)
.arg(args)
.arg(exitCode) .arg(exitCode)
.arg(output.isEmpty() ? "(no output)" : output)); .arg(output.isEmpty() ? "(no output)" : output));
} }
} else { } else {
LOG_MESSAGE("ExecuteTerminalCommandTool: Command crashed or was terminated"); LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Command '%1' crashed or was "
"terminated (output size: %2 bytes)")
.arg(fullCommand)
.arg(*outputSize));
const QString error = process->errorString(); const QString error = process->errorString();
promise->addResult( promise->addResult(
QString("Command '%1 %2' crashed or was terminated.\n\nError: %3\n\nOutput:\n%4") QString("Command '%1' crashed or was terminated.\n\nError: %2\n\nOutput:\n%3")
.arg(command) .arg(fullCommand)
.arg(args)
.arg(error) .arg(error)
.arg(output.isEmpty() ? "(no output)" : output)); .arg(output.isEmpty() ? "(no output)" : output));
} }
@ -189,25 +271,92 @@ QFuture<QString> ExecuteTerminalCommandTool::executeAsync(const QJsonObject &inp
process->deleteLater(); process->deleteLater();
}); });
QObject::connect(process, &QProcess::errorOccurred, [process, promise, command, args]( QObject::connect(process, &QProcess::errorOccurred, [process, promise, command, args, timeoutTimer](
QProcess::ProcessError error) { QProcess::ProcessError error) {
LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Process error occurred: %1").arg(error)); if (promise->future().isFinished()) {
const QString errorString = process->errorString(); return;
promise->addResult(QString("Error executing command '%1 %2': %3") }
.arg(command)
.arg(args) timeoutTimer->stop();
.arg(errorString)); timeoutTimer->deleteLater();
const QString fullCommand = args.isEmpty() ? command : QString("%1 %2").arg(command).arg(args);
LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Process error occurred for '%1': %2 (%3)")
.arg(fullCommand)
.arg(error)
.arg(process->errorString()));
QString errorMessage;
switch (error) {
case QProcess::FailedToStart:
errorMessage = QString("Failed to start command '%1'. The command may not exist or "
"you may not have permission to execute it.")
.arg(fullCommand);
break;
case QProcess::Crashed:
errorMessage = QString("Command '%1' crashed during execution.").arg(fullCommand);
break;
case QProcess::Timedout:
errorMessage = QString("Command '%1' timed out.").arg(fullCommand);
break;
case QProcess::WriteError:
errorMessage = QString("Write error occurred while executing '%1'.").arg(fullCommand);
break;
case QProcess::ReadError:
errorMessage = QString("Read error occurred while executing '%1'.").arg(fullCommand);
break;
default:
errorMessage = QString("Unknown error occurred while executing '%1': %2")
.arg(fullCommand)
.arg(process->errorString());
break;
}
promise->addResult(QString("Error: %1").arg(errorMessage));
promise->finish(); promise->finish();
process->deleteLater(); process->deleteLater();
}); });
if (args.isEmpty()) { QString fullCommand = command;
process->start(command, QStringList()); if (!args.isEmpty()) {
} else { fullCommand += " " + args;
QStringList argList = args.split(QRegularExpression("\\s+"), Qt::SkipEmptyParts);
process->start(command, argList);
} }
QStringList splitCommand = QProcess::splitCommand(fullCommand);
if (splitCommand.isEmpty()) {
LOG_MESSAGE("ExecuteTerminalCommandTool: Failed to parse command");
promise->addResult(QString("Error: Failed to parse command '%1'").arg(fullCommand));
promise->finish();
process->deleteLater();
timeoutTimer->deleteLater();
return future;
}
const QString program = splitCommand.takeFirst();
process->start(program, splitCommand);
if (!process->waitForStarted(PROCESS_START_TIMEOUT_MS)) {
LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Failed to start command '%1' within %2ms")
.arg(fullCommand)
.arg(PROCESS_START_TIMEOUT_MS));
const QString errorString = process->errorString();
promise->addResult(QString("Error: Failed to start command '%1': %2\n\n"
"Possible reasons:\n"
"- Command not found in PATH\n"
"- Insufficient permissions\n"
"- Invalid command syntax")
.arg(fullCommand)
.arg(errorString));
promise->finish();
process->deleteLater();
timeoutTimer->deleteLater();
return future;
}
LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Process started successfully (PID: %1)")
.arg(process->processId()));
timeoutTimer->start();
return future; return future;
} }
@ -217,24 +366,127 @@ bool ExecuteTerminalCommandTool::isCommandAllowed(const QString &command) const
return allowed.contains(command, Qt::CaseInsensitive); return allowed.contains(command, Qt::CaseInsensitive);
} }
bool ExecuteTerminalCommandTool::isCommandSafe(const QString &command) const
{
static const QRegularExpression safePattern("^[a-zA-Z0-9._/-]+$");
return safePattern.match(command).hasMatch();
}
bool ExecuteTerminalCommandTool::areArgumentsSafe(const QString &args) const
{
if (args.isEmpty()) {
return true;
}
static const QStringList dangerousPatterns = {
";", // Command separator
"&&", // AND operator
"||", // OR operator
"|", // Pipe operator
">", // Output redirection
">>", // Append redirection
"<", // Input redirection
"`", // Command substitution
"$(", // Command substitution
"$()", // Command substitution
"\\n", // Newline (could start new command)
"\\r" // Carriage return
};
for (const QString &pattern : dangerousPatterns) {
if (args.contains(pattern)) {
LOG_MESSAGE(QString("ExecuteTerminalCommandTool: Dangerous pattern '%1' found in args")
.arg(pattern));
return false;
}
}
return true;
}
QString ExecuteTerminalCommandTool::sanitizeOutput(const QString &output, qint64 totalSize) const
{
if (totalSize > MAX_OUTPUT_SIZE) {
const QString truncated = output.left(MAX_OUTPUT_SIZE / 2);
return QString("%1\n\n... [Output truncated: exceeded maximum size of %2 MB. "
"Total output size was %3 bytes] ...")
.arg(truncated)
.arg(MAX_OUTPUT_SIZE / (1024 * 1024))
.arg(totalSize);
}
return output;
}
QStringList ExecuteTerminalCommandTool::getAllowedCommands() const QStringList ExecuteTerminalCommandTool::getAllowedCommands() const
{ {
const QString commandsStr static QString cachedCommandsStr;
= Settings::toolsSettings().allowedTerminalCommands().trimmed(); static QStringList cachedCommands;
QString commandsStr;
// Get commands for current OS
#ifdef Q_OS_LINUX
commandsStr = Settings::toolsSettings().allowedTerminalCommandsLinux().trimmed();
#elif defined(Q_OS_MACOS)
commandsStr = Settings::toolsSettings().allowedTerminalCommandsMacOS().trimmed();
#elif defined(Q_OS_WIN)
commandsStr = Settings::toolsSettings().allowedTerminalCommandsWindows().trimmed();
#else
commandsStr = Settings::toolsSettings().allowedTerminalCommandsLinux().trimmed(); // fallback
#endif
// Return cached result if settings haven't changed
if (commandsStr == cachedCommandsStr && !cachedCommands.isEmpty()) {
return cachedCommands;
}
// Update cache
cachedCommandsStr = commandsStr;
cachedCommands.clear();
if (commandsStr.isEmpty()) { if (commandsStr.isEmpty()) {
return QStringList(); return QStringList();
} }
const QStringList rawCommands = commandsStr.split(',', Qt::SkipEmptyParts); const QStringList rawCommands = commandsStr.split(',', Qt::SkipEmptyParts);
QStringList commands; cachedCommands.reserve(rawCommands.size());
commands.reserve(rawCommands.size());
for (const QString &cmd : rawCommands) { for (const QString &cmd : rawCommands) {
commands.append(cmd.trimmed()); const QString trimmed = cmd.trimmed();
if (!trimmed.isEmpty()) {
cachedCommands.append(trimmed);
}
} }
return commands; return cachedCommands;
}
QString ExecuteTerminalCommandTool::getCommandDescription() const
{
const QStringList allowed = getAllowedCommands();
const QString allowedList = allowed.isEmpty() ? "none" : allowed.join(", ");
#ifdef Q_OS_LINUX
const QString osInfo = " Running on Linux.";
#elif defined(Q_OS_MACOS)
const QString osInfo = " Running on macOS.";
#elif defined(Q_OS_WIN)
const QString osInfo = " Running on Windows.";
#else
const QString osInfo = "";
#endif
return QString(
"Execute a terminal command in the project directory. "
"Only commands from the allowed list can be executed. "
"Currently allowed commands for this OS: %1. "
"The command will be executed in the root directory of the active project. "
"Commands have a %2 second timeout. "
"Returns the command output (stdout and stderr) or an error message if the command fails.%3")
.arg(allowedList)
.arg(COMMAND_TIMEOUT_MS / 1000)
.arg(osInfo);
} }
} // namespace QodeAssist::Tools } // namespace QodeAssist::Tools

View File

@ -40,7 +40,18 @@ public:
private: private:
bool isCommandAllowed(const QString &command) const; bool isCommandAllowed(const QString &command) const;
bool isCommandSafe(const QString &command) const;
bool areArgumentsSafe(const QString &args) const;
QStringList getAllowedCommands() const; QStringList getAllowedCommands() const;
QString getCommandDescription() const;
QString sanitizeOutput(const QString &output, qint64 maxSize) const;
// Constants for production safety
static constexpr int COMMAND_TIMEOUT_MS = 30000; // 30 seconds
static constexpr qint64 MAX_OUTPUT_SIZE = 10 * 1024 * 1024; // 10 MB
static constexpr int MAX_COMMAND_LENGTH = 1024;
static constexpr int MAX_ARGS_LENGTH = 4096;
static constexpr int PROCESS_START_TIMEOUT_MS = 3000;
}; };
} // namespace QodeAssist::Tools } // namespace QodeAssist::Tools

View File

@ -158,11 +158,11 @@ QJsonArray ToolsFactory::getToolsDefinitions(
} }
} }
// if (requiredPerms.testFlag(LLMCore::ToolPermission::NetworkAccess)) { if (requiredPerms.testFlag(LLMCore::ToolPermission::NetworkAccess)) {
// if (!settings.allowNetworkAccess()) { if (!settings.allowNetworkAccess()) {
// hasPermission = false; hasPermission = false;
// } }
// } }
if (hasPermission) { if (hasPermission) {
toolsArray.append(it.value()->getDefinition(format)); toolsArray.append(it.value()->getDefinition(format));