- 앱 생성 진행/도구/완료 카드에 전용 최대폭을 도입하고 좌측 정렬로 통일 - 라이브 진행 카드와 검증 게이트 문구에서 깨져 보이던 문자열을 정상화 - build_run/process 도구가 Windows 기본 출력 인코딩을 우선 사용하도록 조정 - README와 DEVELOPMENT 문서에 2026-04-16 00:57 (KST) 기준 이력 반영 검증: - dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify_agent_ui_layout_encoding\ -p:IntermediateOutputPath=obj\verify_agent_ui_layout_encoding\ (경고 0 / 오류 0) - dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "ChatWindowSlashPolicyTests|AgentLoopCodeQualityTests" -p:OutputPath=bin\verify_agent_ui_layout_encoding_tests\ -p:IntermediateOutputPath=obj\verify_agent_ui_layout_encoding_tests\ (통과 194)
137 lines
5.6 KiB
C#
137 lines
5.6 KiB
C#
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
|
|
namespace AxCopilot.Services.Agent;
|
|
|
|
/// <summary>cmd/powershell 명령을 실행하는 도구. 타임아웃 및 위험 명령 차단 포함.</summary>
|
|
public class ProcessTool : IAgentTool
|
|
{
|
|
public string Name => "process";
|
|
public string Description => "Execute a shell command (cmd or powershell). Returns stdout and stderr. Has a timeout limit.";
|
|
|
|
public ToolParameterSchema Parameters => new()
|
|
{
|
|
Properties = new()
|
|
{
|
|
["command"] = new() { Type = "string", Description = "Command to execute" },
|
|
["shell"] = new() { Type = "string", Description = "Shell to use: 'cmd' or 'powershell'. Default: 'cmd'.",
|
|
Enum = ["cmd", "powershell"] },
|
|
["timeout"] = new() { Type = "integer", Description = "Timeout in seconds. Default: 30, max: 120." },
|
|
},
|
|
Required = ["command"]
|
|
};
|
|
|
|
// 위험 명령 패턴 (대소문자 무시)
|
|
private static readonly string[] DangerousPatterns =
|
|
[
|
|
"format ", "del /s", "rd /s", "rmdir /s",
|
|
"rm -rf", "Remove-Item -Recurse -Force",
|
|
"Stop-Computer", "Restart-Computer",
|
|
"shutdown", "taskkill /f",
|
|
"reg delete", "reg add",
|
|
"net user", "net localgroup",
|
|
"schtasks /create", "schtasks /delete",
|
|
];
|
|
|
|
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
|
{
|
|
if (!args.SafeTryGetProperty("command", out var cmdEl))
|
|
return ToolResult.Fail("command가 필요합니다.");
|
|
var command = cmdEl.SafeGetString() ?? "";
|
|
var shell = args.SafeTryGetProperty("shell", out var sh) ? sh.SafeGetString() ?? "cmd" : "cmd";
|
|
var timeout = args.SafeTryGetProperty("timeout", out var to) ? Math.Min(to.GetInt32(), 120) : 30;
|
|
|
|
if (string.IsNullOrWhiteSpace(command))
|
|
return ToolResult.Fail("명령이 비어 있습니다.");
|
|
|
|
if (AxCopilot.Services.OperationModePolicy.IsInternal(context.OperationMode)
|
|
&& AxCopilot.Services.OperationModePolicy.IsBlockedShellCommandInInternalMode(command))
|
|
return ToolResult.Fail("사내 모드에서는 외부 네트워크 접근 가능성이 있는 명령 실행이 차단됩니다.");
|
|
|
|
// 위험 명령 차단
|
|
foreach (var pattern in DangerousPatterns)
|
|
{
|
|
if (command.Contains(pattern, StringComparison.OrdinalIgnoreCase))
|
|
return ToolResult.Fail($"위험 명령 차단: '{pattern}' 패턴이 감지되었습니다.");
|
|
}
|
|
|
|
// 권한 확인
|
|
if (!await context.CheckWritePermissionAsync(Name, command))
|
|
return ToolResult.Fail("명령 실행 권한 거부");
|
|
|
|
try
|
|
{
|
|
var (fileName, arguments) = shell == "powershell"
|
|
? ("powershell.exe", $"-NoProfile -NonInteractive -Command \"{command.Replace("\"", "\\\"")}\"")
|
|
: ("cmd.exe", $"/C {command}");
|
|
|
|
var psi = new ProcessStartInfo
|
|
{
|
|
FileName = fileName,
|
|
Arguments = arguments,
|
|
UseShellExecute = false,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
CreateNoWindow = true,
|
|
StandardOutputEncoding = ResolveProcessOutputEncoding(),
|
|
StandardErrorEncoding = ResolveProcessOutputEncoding(),
|
|
};
|
|
|
|
// 작업 폴더 설정
|
|
if (!string.IsNullOrEmpty(context.WorkFolder) && Directory.Exists(context.WorkFolder))
|
|
psi.WorkingDirectory = context.WorkFolder;
|
|
|
|
using var process = new Process { StartInfo = psi };
|
|
var stdout = new StringBuilder();
|
|
var stderr = new StringBuilder();
|
|
|
|
process.OutputDataReceived += (_, e) => { if (e.Data != null) stdout.AppendLine(e.Data); };
|
|
process.ErrorDataReceived += (_, e) => { if (e.Data != null) stderr.AppendLine(e.Data); };
|
|
|
|
process.Start();
|
|
process.BeginOutputReadLine();
|
|
process.BeginErrorReadLine();
|
|
|
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
cts.CancelAfter(TimeSpan.FromSeconds(timeout));
|
|
|
|
try
|
|
{
|
|
await process.WaitForExitAsync(cts.Token);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
try { process.Kill(entireProcessTree: true); } catch { }
|
|
return ToolResult.Fail($"명령 실행 타임아웃 ({timeout}초 초과)");
|
|
}
|
|
|
|
var output = stdout.ToString().TrimEnd();
|
|
var error = stderr.ToString().TrimEnd();
|
|
|
|
// 출력 크기 제한 (8000자)
|
|
if (output.Length > 8000)
|
|
output = output[..8000] + "\n... (출력 잘림)";
|
|
|
|
var result = new StringBuilder();
|
|
result.AppendLine($"[Exit code: {process.ExitCode}]");
|
|
if (!string.IsNullOrEmpty(output))
|
|
result.AppendLine(output);
|
|
if (!string.IsNullOrEmpty(error))
|
|
result.AppendLine($"[stderr]\n{error}");
|
|
|
|
return process.ExitCode == 0
|
|
? ToolResult.Ok(result.ToString())
|
|
: ToolResult.Ok(result.ToString()); // 비정상 종료도 결과로 반환 (LLM이 판단)
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return ToolResult.Fail($"명령 실행 실패: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private static Encoding ResolveProcessOutputEncoding()
|
|
=> OperatingSystem.IsWindows() ? Encoding.Default : Encoding.UTF8;
|
|
}
|