Some checks failed
Release Gate / gate (push) Has been cancelled
- claude-code 선택적 탐색 흐름을 참고해 Cowork/Code 시스템 프롬프트에서 folder_map 상시 선행 지시를 완화하고 glob/grep 기반 좁은 탐색을 우선하도록 조정함 - FolderMapTool 기본 depth를 2로, include_files 기본값을 false로 낮추고 MultiReadTool 최대 파일 수를 8개로 줄여 초기 과탐색 폭을 보수적으로 조정함 - AgentLoopExplorationPolicy partial을 추가해 탐색 범위 분류, broad-scan corrective hint, exploration_breadth 성능 로그를 연결함 - AgentLoopService에 탐색 범위 가이드 주입과 실행 중 탐색 폭 추적을 추가하고, 좁은 질문에서 반복적인 folder_map/대량 multi_read를 교정하도록 정리함 - DocxToHtmlConverter nullable 경고를 수정해 Release 빌드 경고 0 / 오류 0 기준을 다시 충족함 - README와 docs/DEVELOPMENT.md에 2026-04-09 10:36 (KST) 기준 개발 이력을 반영함
168 lines
6.4 KiB
C#
168 lines
6.4 KiB
C#
using System.IO;
|
|
using System.IO.Compression;
|
|
using System.Text.Json;
|
|
|
|
namespace AxCopilot.Services.Agent;
|
|
|
|
/// <summary>
|
|
/// 파일 압축(zip) / 해제 도구.
|
|
/// </summary>
|
|
public class ZipTool : IAgentTool
|
|
{
|
|
public string Name => "zip_tool";
|
|
public string Description =>
|
|
"Compress or extract zip archives. Actions: " +
|
|
"'compress' — create a zip file from files/folders; " +
|
|
"'extract' — extract a zip file to a directory; " +
|
|
"'list' — list contents of a zip file without extracting.";
|
|
|
|
public ToolParameterSchema Parameters => new()
|
|
{
|
|
Properties = new()
|
|
{
|
|
["action"] = new()
|
|
{
|
|
Type = "string",
|
|
Description = "Action to perform",
|
|
Enum = ["compress", "extract", "list"],
|
|
},
|
|
["zip_path"] = new()
|
|
{
|
|
Type = "string",
|
|
Description = "Path to the zip file (to create or extract)",
|
|
},
|
|
["source_path"] = new()
|
|
{
|
|
Type = "string",
|
|
Description = "Source file or directory path (for compress action)",
|
|
},
|
|
["dest_path"] = new()
|
|
{
|
|
Type = "string",
|
|
Description = "Destination directory (for extract action)",
|
|
},
|
|
},
|
|
Required = ["action", "zip_path"],
|
|
};
|
|
|
|
private const long MaxExtractSize = 500 * 1024 * 1024; // 500MB 제한
|
|
|
|
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
|
{
|
|
var action = args.GetProperty("action").SafeGetString() ?? "";
|
|
var zipPath = args.GetProperty("zip_path").SafeGetString() ?? "";
|
|
|
|
if (!Path.IsPathRooted(zipPath))
|
|
zipPath = Path.Combine(context.WorkFolder, zipPath);
|
|
|
|
try
|
|
{
|
|
return Task.FromResult(action switch
|
|
{
|
|
"compress" => Compress(args, zipPath, context),
|
|
"extract" => Extract(args, zipPath, context),
|
|
"list" => ListContents(zipPath),
|
|
_ => ToolResult.Fail($"Unknown action: {action}"),
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return Task.FromResult(ToolResult.Fail($"Zip 오류: {ex.Message}"));
|
|
}
|
|
}
|
|
|
|
private static ToolResult Compress(JsonElement args, string zipPath, AgentContext context)
|
|
{
|
|
if (!args.SafeTryGetProperty("source_path", out var sp))
|
|
return ToolResult.Fail("'source_path' is required for compress action");
|
|
|
|
var sourcePath = sp.SafeGetString() ?? "";
|
|
if (!Path.IsPathRooted(sourcePath))
|
|
sourcePath = Path.Combine(context.WorkFolder, sourcePath);
|
|
|
|
if (File.Exists(zipPath))
|
|
return ToolResult.Fail($"Zip file already exists: {zipPath}");
|
|
|
|
if (Directory.Exists(sourcePath))
|
|
{
|
|
ZipFile.CreateFromDirectory(sourcePath, zipPath, CompressionLevel.Optimal, includeBaseDirectory: false);
|
|
var info = new FileInfo(zipPath);
|
|
return ToolResult.Ok($"✓ Created {zipPath} ({info.Length / 1024}KB)", zipPath);
|
|
}
|
|
else if (File.Exists(sourcePath))
|
|
{
|
|
using var zip = ZipFile.Open(zipPath, ZipArchiveMode.Create);
|
|
zip.CreateEntryFromFile(sourcePath, Path.GetFileName(sourcePath), CompressionLevel.Optimal);
|
|
var info = new FileInfo(zipPath);
|
|
return ToolResult.Ok($"✓ Created {zipPath} ({info.Length / 1024}KB)", zipPath);
|
|
}
|
|
else
|
|
{
|
|
return ToolResult.Fail($"Source not found: {sourcePath}");
|
|
}
|
|
}
|
|
|
|
private static ToolResult Extract(JsonElement args, string zipPath, AgentContext context)
|
|
{
|
|
if (!File.Exists(zipPath))
|
|
return ToolResult.Fail($"Zip file not found: {zipPath}");
|
|
|
|
var destPath = args.SafeTryGetProperty("dest_path", out var dp)
|
|
? dp.SafeGetString() ?? "" : "";
|
|
if (string.IsNullOrEmpty(destPath))
|
|
destPath = Path.Combine(Path.GetDirectoryName(zipPath) ?? context.WorkFolder,
|
|
Path.GetFileNameWithoutExtension(zipPath));
|
|
if (!Path.IsPathRooted(destPath))
|
|
destPath = Path.Combine(context.WorkFolder, destPath);
|
|
|
|
// 사이즈 체크
|
|
using (var check = ZipFile.OpenRead(zipPath))
|
|
{
|
|
var totalSize = check.Entries.Sum(e => e.Length);
|
|
if (totalSize > MaxExtractSize)
|
|
return ToolResult.Fail($"Uncompressed size ({totalSize / 1024 / 1024}MB) exceeds 500MB limit");
|
|
|
|
// 보안: 상위 경로 이탈 방지
|
|
foreach (var entry in check.Entries)
|
|
{
|
|
var fullPath = Path.GetFullPath(Path.Combine(destPath, entry.FullName));
|
|
if (!fullPath.StartsWith(Path.GetFullPath(destPath), StringComparison.OrdinalIgnoreCase))
|
|
return ToolResult.Fail($"Security: entry '{entry.FullName}' escapes destination directory");
|
|
}
|
|
}
|
|
|
|
Directory.CreateDirectory(destPath);
|
|
ZipFile.ExtractToDirectory(zipPath, destPath, overwriteFiles: true);
|
|
|
|
var fileCount = Directory.GetFiles(destPath, "*", SearchOption.AllDirectories).Length;
|
|
return ToolResult.Ok($"✓ Extracted {fileCount} files to {destPath}");
|
|
}
|
|
|
|
private static ToolResult ListContents(string zipPath)
|
|
{
|
|
if (!File.Exists(zipPath))
|
|
return ToolResult.Fail($"Zip file not found: {zipPath}");
|
|
|
|
using var zip = ZipFile.OpenRead(zipPath);
|
|
var sb = new System.Text.StringBuilder();
|
|
sb.AppendLine($"Archive: {Path.GetFileName(zipPath)} ({new FileInfo(zipPath).Length / 1024}KB)");
|
|
sb.AppendLine($"Entries: {zip.Entries.Count}");
|
|
sb.AppendLine();
|
|
sb.AppendLine($"{"Size",10} {"Compressed",10} {"Name"}");
|
|
sb.AppendLine(new string('-', 60));
|
|
|
|
var limit = Math.Min(zip.Entries.Count, 200);
|
|
foreach (var entry in zip.Entries.Take(limit))
|
|
{
|
|
sb.AppendLine($"{entry.Length,10} {entry.CompressedLength,10} {entry.FullName}");
|
|
}
|
|
if (zip.Entries.Count > limit)
|
|
sb.AppendLine($"\n... and {zip.Entries.Count - limit} more entries");
|
|
|
|
var totalUncompressed = zip.Entries.Sum(e => e.Length);
|
|
sb.AppendLine($"\nTotal uncompressed: {totalUncompressed / 1024}KB");
|
|
|
|
return ToolResult.Ok(sb.ToString());
|
|
}
|
|
}
|