using ClawdDotNet.Core.Sandbox; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; namespace ClawdDotNet.Core.Tests.Sandbox; /// /// Runs only where the real launcher can run: Linux with a sandbox, and the application built /// — CLAWD_TEST_HELPER names its executable (a test host cannot be the launcher). /// public sealed class SandboxLaunchFactAttribute : FactAttribute { public SandboxLaunchFactAttribute() { if (SandboxEscapeTests.Runner.UnavailableReason is { } reason) Skip = "No sandbox here: " + reason; else if (SandboxLaunchTests.Helper is null) Skip = "Set CLAWD_TEST_HELPER to the built ClawdDotNet executable."; } } /// /// #712: The launcher inside the real sandbox — secrets arrive in the program's environment /// and nowhere else, and the network is the proxy's list and nothing more. /// public sealed class SandboxLaunchTests : IDisposable { internal static readonly SandboxHelper? Helper = Environment.GetEnvironmentVariable("CLAWD_TEST_HELPER") is { Length: > 0 } path && File.Exists(path) ? SandboxHelper.For(path) : null; private readonly string _workspace = Path.Combine(Path.GetTempPath(), "clawd-tests", "launch-" + Guid.NewGuid().ToString("N")[..8], "Workspace"); private readonly BwrapSandbox _runner; public SandboxLaunchTests() { Directory.CreateDirectory(_workspace); _runner = BwrapSandbox.Detect(NullLogger.Instance) as BwrapSandbox ?? null!; if (_runner is not null) _runner.Helper = Helper; } public void Dispose() { try { Directory.Delete(Path.GetDirectoryName(_workspace)!, recursive: true); } catch { /* cleanup is secondary */ } } private Task Sh(string script, IReadOnlyList? egress = null, IReadOnlyDictionary? secrets = null) => _runner.RunAsync(new SandboxRequest { Executable = "/bin/sh", Arguments = ["-c", script], WorkspacePath = _workspace, Egress = egress ?? [], SecretEnvironment = secrets ?? new Dictionary(), Limits = new SandboxLimits { Timeout = TimeSpan.FromSeconds(60), MaxMemoryBytes = 0, MaxProcesses = 256 } }, CancellationToken.None); [SandboxLaunchFact] public async Task A_secret_reaches_the_program_and_no_command_line() { var result = await Sh("""echo "key=$ANTHROPIC_API_KEY"; for f in /proc/[0-9]*/cmdline; do tr '\0' ' ' < "$f"; echo; done""", secrets: new Dictionary { ["ANTHROPIC_API_KEY"] = "sk-ant-test-4711" }); result.ExitCode.ShouldBe(0, result.StandardError); var lines = result.StandardOutput.Split('\n'); lines[0].ShouldBe("key=sk-ant-test-4711"); lines.Skip(1).ShouldNotContain(l => l.Contains("sk-ant-test-4711")); } [SandboxLaunchFact] public async Task A_listed_host_is_reached_through_the_proxy() { var result = await Sh("curl -sS -o /dev/null -w '%{http_code}' --max-time 20 https://dc.mhdf.de/", egress: ["dc.mhdf.de"]); result.ExitCode.ShouldBe(0, result.StandardError); result.StandardOutput.ShouldNotBe("000"); } [SandboxLaunchFact] public async Task A_host_that_is_not_listed_is_not() { var result = await Sh("curl -sS -o /dev/null --max-time 20 https://example.com/", egress: ["dc.mhdf.de"]); result.ExitCode.ShouldNotBe(0); result.StandardError.ShouldContain("403"); } [SandboxLaunchFact] public async Task Past_the_proxy_there_is_no_network() { var result = await Sh("curl -sS -o /dev/null --max-time 10 --noproxy '*' https://dc.mhdf.de/", egress: ["dc.mhdf.de"]); result.ExitCode.ShouldNotBe(0, "without the proxy the sandbox has only its own loopback"); } [SandboxLaunchFact] public async Task The_instances_loopback_is_not_the_sandboxes() { // Something listening on the host's loopback — the instance's web port, a database. var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); listener.Start(); var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; try { var result = await Sh($"curl -sS -o /dev/null --max-time 5 --noproxy '*' http://127.0.0.1:{port}/", egress: ["dc.mhdf.de"]); result.ExitCode.ShouldNotBe(0); } finally { listener.Stop(); } } }