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();
}
}
}