using System.Net.Http; using System.Text; using System.Text.Json; namespace AxCopilot.Services.Agent; /// /// 로컬/사내 HTTP API 호출 도구. /// GET/POST/PUT/DELETE 요청, JSON 파싱, 헤더 설정을 지원합니다. /// public class HttpTool : IAgentTool { public string Name => "http_tool"; public string Description => "Make HTTP requests to local or internal APIs. " + "Supports GET, POST, PUT, DELETE methods with JSON body and custom headers. " + "Only allows localhost and internal network addresses (security restriction). " + "Use this for testing APIs, fetching data from internal services, or webhooks."; public ToolParameterSchema Parameters => new() { Properties = new() { ["method"] = new() { Type = "string", Description = "HTTP method", Enum = ["GET", "POST", "PUT", "DELETE", "PATCH"], }, ["url"] = new() { Type = "string", Description = "Request URL (localhost or internal network only)", }, ["body"] = new() { Type = "string", Description = "Request body (JSON string, for POST/PUT/PATCH)", }, ["headers"] = new() { Type = "string", Description = "Custom headers as JSON object, e.g. {\"Authorization\": \"Bearer token\"}", }, ["timeout"] = new() { Type = "string", Description = "Request timeout in seconds (default: 30, max: 120)", }, }, Required = ["method", "url"], }; private static readonly HttpClient _client = new() { Timeout = TimeSpan.FromSeconds(30) }; public async Task ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default) { var method = args.GetProperty("method").GetString()?.ToUpperInvariant() ?? "GET"; var url = args.GetProperty("url").GetString() ?? ""; var body = args.TryGetProperty("body", out var b) ? b.GetString() ?? "" : ""; var headers = args.TryGetProperty("headers", out var h) ? h.GetString() ?? "" : ""; var timeout = args.TryGetProperty("timeout", out var t) ? int.TryParse(t.GetString(), out var ts) ? Math.Min(ts, 120) : 30 : 30; // 보안: 허용된 호스트만 if (!IsAllowedHost(url)) return ToolResult.Fail("보안 제한: localhost, 127.0.0.1, 사내 네트워크(10.x, 172.16-31.x, 192.168.x)만 허용됩니다."); try { var httpMethod = new HttpMethod(method); using var request = new HttpRequestMessage(httpMethod, url); // 헤더 설정 if (!string.IsNullOrEmpty(headers)) { using var headerDoc = JsonDocument.Parse(headers); foreach (var prop in headerDoc.RootElement.EnumerateObject()) request.Headers.TryAddWithoutValidation(prop.Name, prop.Value.GetString()); } // 본문 설정 if (!string.IsNullOrEmpty(body) && method is "POST" or "PUT" or "PATCH") request.Content = new StringContent(body, Encoding.UTF8, "application/json"); using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(TimeSpan.FromSeconds(timeout)); using var response = await _client.SendAsync(request, cts.Token); var statusCode = (int)response.StatusCode; var responseBody = await response.Content.ReadAsStringAsync(cts.Token); // 응답 포맷팅 var sb = new StringBuilder(); sb.AppendLine($"HTTP {statusCode} {response.ReasonPhrase}"); sb.AppendLine($"Content-Type: {response.Content.Headers.ContentType}"); sb.AppendLine(); // JSON이면 포맷 if (response.Content.Headers.ContentType?.MediaType?.Contains("json") == true) { try { using var doc = JsonDocument.Parse(responseBody); responseBody = JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions { WriteIndented = true }); } catch { /* not valid JSON, keep raw */ } } if (responseBody.Length > 8000) responseBody = responseBody[..8000] + $"\n... (truncated, total {responseBody.Length} chars)"; sb.Append(responseBody); return ToolResult.Ok(sb.ToString()); } catch (TaskCanceledException) { return ToolResult.Fail($"요청 시간 초과 ({timeout}초)"); } catch (Exception ex) { return ToolResult.Fail($"HTTP 요청 실패: {ex.Message}"); } } private static bool IsAllowedHost(string url) { if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return false; var host = uri.Host; if (host is "localhost" or "127.0.0.1" or "::1") return true; // 사내 네트워크 대역 if (System.Net.IPAddress.TryParse(host, out var ip)) { var bytes = ip.GetAddressBytes(); if (bytes.Length == 4) { if (bytes[0] == 10) return true; // 10.0.0.0/8 if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return true; // 172.16.0.0/12 if (bytes[0] == 192 && bytes[1] == 168) return true; // 192.168.0.0/16 } } return false; } }