Fremde Laufzeiten: Claude Code und Befehl in der Sandbox, Egress-Proxy, Vorschlag statt Einsatz (#712)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-25 22:46:49 +02:00
co-authored by Claude Opus 5.5
parent 7d6c2c9495
commit 367ea387f6
40 changed files with 3428 additions and 29 deletions
@@ -0,0 +1,255 @@
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;
/// <summary>
/// #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, <see cref="SandboxEscapeTests"/> tries.
/// </summary>
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<ArgumentException>(() => 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<IPAddress[]> Loopback(string host, CancellationToken ct) => Task.FromResult(new[] { IPAddress.Loopback });
/// <summary>A server that answers every line with "echo: " and the line.</summary>
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<string, string> { ["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);
}
}
@@ -173,6 +173,79 @@ public sealed class SandboxDefinitionTests
line.ShouldNotContain("--clearenv");
}
// ═══════════════════ #712: launcher, network, secrets, mounts ═══════════════════
private static readonly SandboxHelper Helper = new(
"/opt/clawd/ClawdDotNet", ["/opt/clawd"], new Dictionary<string, string> { ["DOTNET_ROOT"] = "/usr/lib/dotnet" });
private static IReadOnlyList<string> LaunchLine(SandboxRequest request)
=> BwrapSandbox.CommandLine("/usr/bin/bwrap", "/tmp/f", request, HostLayout.MergedUsr, BwrapFeatures.All,
Helper, new LaunchFiles("/tmp/p/egress.sock", "/tmp/p/env.json"));
private static SandboxRequest ForeignRequest() => new()
{
Executable = "/opt/claude/claude",
Arguments = ["-p"],
WorkspacePath = Workspace,
Egress = ["api.anthropic.com"],
SecretEnvironment = new Dictionary<string, string> { ["ANTHROPIC_API_KEY"] = "sk-ant-secret" },
Mounts = [new SandboxMount("/srv/agent/home", "/home/agent", Writable: true), new SandboxMount("/srv/run/private", "/run/agent")]
};
[Fact]
public void A_run_with_network_or_secrets_starts_behind_the_launcher()
{
var line = LaunchLine(ForeignRequest());
line.TakeLast(9).ShouldBe(["/opt/clawd/ClawdDotNet", "--sandbox-launch",
"--egress", SandboxPaths.EgressSocket, "--secrets", SandboxPaths.SecretFile, "--", "/opt/claude/claude", "-p"]);
HasSequence(line, "--bind", "/tmp/p/egress.sock", SandboxPaths.EgressSocket).ShouldBeTrue();
HasSequence(line, "--ro-bind", "/tmp/p/env.json", SandboxPaths.SecretFile).ShouldBeTrue();
HasSequence(line, "--ro-bind", "/opt/clawd", "/opt/clawd").ShouldBeTrue();
HasSequence(line, "--setenv", "HTTPS_PROXY", $"http://127.0.0.1:{SandboxPaths.ProxyPort}").ShouldBeTrue();
}
[Fact]
public void The_sandbox_keeps_its_own_network_even_with_egress()
{
var line = LaunchLine(ForeignRequest());
line.ShouldContain("--unshare-all");
line.ShouldNotContain("--share-net");
}
[Fact]
public void A_secret_is_never_on_the_command_line()
=> LaunchLine(ForeignRequest()).ShouldNotContain(a => a.Contains("sk-ant-secret"));
[Fact]
public void Mounts_come_at_their_own_places_and_only_there()
{
var line = LaunchLine(ForeignRequest());
HasSequence(line, "--bind", Path.GetFullPath("/srv/agent/home"), "/home/agent").ShouldBeTrue();
HasSequence(line, "--ro-bind", Path.GetFullPath("/srv/run/private"), "/run/agent").ShouldBeTrue();
foreach (var target in new[] { "/work/.claude", "/work", "relative", "/home/../etc" })
Should.Throw<ArgumentException>(() => LaunchLine(ForeignRequest() with { Mounts = [new SandboxMount("/srv/x", target)] }), target);
}
[Fact]
public void Without_the_launcher_such_a_run_is_refused_not_run_bare()
=> Should.Throw<ArgumentException>(() => BwrapSandbox.CommandLine("/usr/bin/bwrap", "/tmp/f", ForeignRequest(),
HostLayout.MergedUsr, BwrapFeatures.All));
[Fact]
public void No_memory_limit_is_written_as_unlimited()
{
var line = Line(new SandboxRequest
{
Executable = "true", WorkspacePath = Workspace, Limits = new SandboxLimits { MaxMemoryBytes = 0 }
});
line[4].ShouldBe("unlimited");
}
[Theory]
[InlineData("in.mp3", "/work/in.mp3")]
[InlineData("YTTranscript/a b.m4a", "/work/YTTranscript/a b.m4a")]
@@ -0,0 +1,116 @@
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();
}
}
}