using System.Net; using System.Net.Sockets; using System.Text; using ClawdDotNet.Core.Sandbox; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; namespace ClawdDotNet.Core.Tests.Sandbox; /// /// #712/#655: The only way out of a sandbox — the list, the address check, the proxy and /// the relay the launcher runs inside. Unix sockets work on every platform .NET runs on, /// so this runs everywhere; that the sandbox has no other way out, tries. /// public sealed class EgressTests : IDisposable { private readonly string _dir = Path.Combine(Path.GetTempPath(), "ce-" + Guid.NewGuid().ToString("N")[..8]); public EgressTests() => Directory.CreateDirectory(_dir); public void Dispose() { try { Directory.Delete(_dir, recursive: true); } catch { /* cleanup is secondary */ } } // ═══════════════════ the list ═══════════════════ [Theory] [InlineData("api.anthropic.com", 443, true)] [InlineData("API.Anthropic.com.", 443, true)] [InlineData("api.anthropic.com", 80, false)] [InlineData("evil.api.anthropic.com", 443, false)] [InlineData("api.anthropic.com.evil.net", 443, false)] [InlineData("files.example.org", 443, true)] [InlineData("example.org", 443, false)] [InlineData("a.b.example.org", 443, true)] [InlineData("git.example.net", 8443, true)] [InlineData("git.example.net", 443, false)] public void Only_listed_hosts_and_ports_pass(string host, int port, bool allowed) => EgressRules.Parse(["api.anthropic.com", "*.example.org", "git.example.net:8443"]).Allows(host, port).ShouldBe(allowed); [Theory] [InlineData("127.0.0.1")] [InlineData("[::1]")] [InlineData("93.184.216.34")] public void An_address_literal_is_never_on_the_list(string host) => EgressRules.Parse(["*.example.org", "93.184.216.34"]).Allows(host, 443).ShouldBeFalse(); [Theory] [InlineData("https://example.org")] [InlineData("*")] [InlineData("*.")] public void What_is_not_a_host_is_refused_when_saved(string entry) => Should.Throw(() => EgressRules.Parse([entry])); // ═══════════════════ public addresses ═══════════════════ [Theory] [InlineData("127.0.0.1")] [InlineData("10.1.2.3")] [InlineData("172.16.0.1")] [InlineData("192.168.40.48")] [InlineData("169.254.169.254")] // cloud metadata [InlineData("100.64.0.1")] [InlineData("0.0.0.0")] [InlineData("224.0.0.1")] [InlineData("::1")] [InlineData("fe80::1")] [InlineData("fd00::1")] [InlineData("::ffff:127.0.0.1")] [InlineData("2002:7f00:1::1")] // 6to4 around 127.0.0.1 [InlineData("64:ff9b::a00:1")] // NAT64 around 10.0.0.1 public void Local_and_private_addresses_are_not_public(string address) => EgressProxy.IsPublic(IPAddress.Parse(address)).ShouldBeFalse(); [Theory] [InlineData("160.79.104.10")] [InlineData("8.8.8.8")] [InlineData("2607:6bc0::10")] public void Addresses_on_the_internet_are(string address) => EgressProxy.IsPublic(IPAddress.Parse(address)).ShouldBeTrue(); // ═══════════════════ the proxy ═══════════════════ private static Task Loopback(string host, CancellationToken ct) => Task.FromResult(new[] { IPAddress.Loopback }); /// A server that answers every line with "echo: " and the line. private static (TcpListener Listener, int Port) EchoServer() { var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); _ = Task.Run(async () => { try { while (true) { var client = await listener.AcceptTcpClientAsync(); _ = Task.Run(async () => { using (client) { var stream = client.GetStream(); var reader = new StreamReader(stream); while (await reader.ReadLineAsync() is { } line) await stream.WriteAsync(Encoding.ASCII.GetBytes("echo: " + line + "\n")); } }); } } catch (Exception) { /* stopped */ } }); return (listener, ((IPEndPoint)listener.LocalEndpoint).Port); } private static async Task<(Stream Stream, string Head)> ConnectAsync(Stream stream, string target) { await stream.WriteAsync(Encoding.ASCII.GetBytes($"CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n")); var head = new StringBuilder(); var one = new byte[1]; while (!head.ToString().EndsWith("\r\n\r\n", StringComparison.Ordinal) && await stream.ReadAsync(one) == 1) head.Append((char)one[0]); return (stream, head.ToString()); } private async Task<(Socket Socket, Stream Stream)> OpenAsync(string socketPath) { var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); await socket.ConnectAsync(new UnixDomainSocketEndPoint(socketPath)); return (socket, new NetworkStream(socket, ownsSocket: true)); } [Fact] public async Task A_listed_host_gets_a_tunnel() { var (server, port) = EchoServer(); var path = Path.Combine(_dir, "e.sock"); using var proxy = EgressProxy.Start(path, [$"service.test:{port}"], NullLogger.Instance, Loopback, _ => true); var (socket, stream) = await OpenAsync(path); using (socket) { var (_, head) = await ConnectAsync(stream, $"service.test:{port}"); head.ShouldStartWith("HTTP/1.1 200"); await stream.WriteAsync("hello\n"u8.ToArray()); var reader = new StreamReader(stream); (await reader.ReadLineAsync()).ShouldBe("echo: hello"); } server.Stop(); } [Fact] public async Task A_host_that_is_not_listed_is_refused() { var path = Path.Combine(_dir, "e.sock"); using var proxy = EgressProxy.Start(path, ["api.anthropic.com"], NullLogger.Instance, Loopback, _ => true); var (socket, stream) = await OpenAsync(path); using (socket) { var (_, head) = await ConnectAsync(stream, "evil.example:443"); head.ShouldStartWith("HTTP/1.1 403"); } proxy.Refusals.ShouldContain(r => r.StartsWith("evil.example:443")); } [Fact] public async Task A_listed_name_that_resolves_to_this_machine_is_refused() { // DNS rebinding: the name is on the list, the address is loopback — the instance's web port. var path = Path.Combine(_dir, "e.sock"); using var proxy = EgressProxy.Start(path, ["api.anthropic.com"], NullLogger.Instance, Loopback); var (socket, stream) = await OpenAsync(path); using (socket) { var (_, head) = await ConnectAsync(stream, "api.anthropic.com:443"); head.ShouldStartWith("HTTP/1.1 403"); } proxy.Refusals.ShouldContain(r => r.Contains("non-public")); } [Fact] public async Task Plain_http_is_not_proxied() { var path = Path.Combine(_dir, "e.sock"); using var proxy = EgressProxy.Start(path, ["example.org"], NullLogger.Instance, Loopback, _ => true); var (socket, stream) = await OpenAsync(path); using (socket) { await stream.WriteAsync("GET http://example.org/ HTTP/1.1\r\nHost: example.org\r\n\r\n"u8.ToArray()); var answer = await new StreamReader(stream).ReadLineAsync(); answer.ShouldStartWith("HTTP/1.1 405"); } } // ═══════════════════ the launcher ═══════════════════ [Fact] public async Task The_relay_carries_the_sandbox_port_to_the_proxy() { var (server, port) = EchoServer(); var path = Path.Combine(_dir, "e.sock"); using var proxy = EgressProxy.Start(path, [$"service.test:{port}"], NullLogger.Instance, Loopback, _ => true); var relayListener = new TcpListener(IPAddress.Loopback, 0); relayListener.Start(); var relayPort = ((IPEndPoint)relayListener.LocalEndpoint).Port; using var stop = new CancellationTokenSource(); var relay = SandboxLauncher.RelayAsync(relayListener, path, stop.Token); using (var client = new TcpClient()) { await client.ConnectAsync(IPAddress.Loopback, relayPort); var (stream, head) = await ConnectAsync(client.GetStream(), $"service.test:{port}"); head.ShouldStartWith("HTTP/1.1 200"); await stream.WriteAsync("through\n"u8.ToArray()); (await new StreamReader(stream).ReadLineAsync()).ShouldBe("echo: through"); } stop.Cancel(); await relay; server.Stop(); } [Fact] public void The_launcher_reads_its_options_and_leaves_the_program_its_own() { var options = SandboxLauncher.Parse(["--sandbox-launch", "--egress", "/run/clawd/egress.sock", "--secrets", "/run/clawd/env.json", "--", "/usr/bin/claude", "-p", "--egress", "x"]); options.ShouldNotBeNull(); options.EgressSocket.ShouldBe("/run/clawd/egress.sock"); options.SecretFile.ShouldBe("/run/clawd/env.json"); options.Program.ShouldBe("/usr/bin/claude"); options.Arguments.ShouldBe(["-p", "--egress", "x"]); SandboxLauncher.Parse(["--sandbox-launch", "--unknown", "--", "x"]).ShouldBeNull(); SandboxLauncher.Parse(["--sandbox-launch", "--egress", "s"]).ShouldBeNull("no program"); } [Fact] public async Task The_secret_file_is_readable_only_by_its_owner() { var file = Path.Combine(_dir, "env.json"); await SandboxLauncher.WriteSecretFileAsync(file, new Dictionary { ["ANTHROPIC_API_KEY"] = "sk-test" }, CancellationToken.None); (await File.ReadAllTextAsync(file)).ShouldContain("sk-test"); if (!OperatingSystem.IsWindows()) File.GetUnixFileMode(file).ShouldBe(UnixFileMode.UserRead | UnixFileMode.UserWrite); } }