117 lines
4.5 KiB
C#
117 lines
4.5 KiB
C#
using ClawdDotNet.Core.Sandbox;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Shouldly;
|
|
|
|
namespace ClawdDotNet.Core.Tests.Sandbox;
|
|
|
|
/// <summary>
|
|
/// Runs only where the real launcher can run: Linux with a sandbox, and the application built
|
|
/// — <c>CLAWD_TEST_HELPER</c> names its executable (a test host cannot be the launcher).
|
|
/// </summary>
|
|
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.";
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// #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.
|
|
/// </summary>
|
|
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<SandboxResult> Sh(string script, IReadOnlyList<string>? egress = null, IReadOnlyDictionary<string, string>? secrets = null)
|
|
=> _runner.RunAsync(new SandboxRequest
|
|
{
|
|
Executable = "/bin/sh",
|
|
Arguments = ["-c", script],
|
|
WorkspacePath = _workspace,
|
|
Egress = egress ?? [],
|
|
SecretEnvironment = secrets ?? new Dictionary<string, string>(),
|
|
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<string, string> { ["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();
|
|
}
|
|
}
|
|
}
|