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:
co-authored by
Claude Opus 5.5
parent
7d6c2c9495
commit
367ea387f6
@@ -45,6 +45,15 @@ public sealed class BwrapSandbox : ISandboxRunner
|
||||
public bool IsAvailable => true;
|
||||
public string? UnavailableReason => null;
|
||||
|
||||
/// <summary>
|
||||
/// #712: The launcher for runs that need network or secrets (<see cref="SandboxRequest.NeedsLauncher"/>).
|
||||
/// Without it such a run is refused, not run without them.
|
||||
/// </summary>
|
||||
public SandboxHelper? Helper { get; set; }
|
||||
|
||||
/// <summary>#712: The logger the egress proxy reports to.</summary>
|
||||
public ILoggerFactory? LoggerFactory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The runner of this machine: bwrap under Linux if it can actually build a sandbox here
|
||||
/// (tried once — a kernel or AppArmor policy that forbids user namespaces shows only when
|
||||
@@ -83,11 +92,36 @@ public sealed class BwrapSandbox : ISandboxRunner
|
||||
public async Task<SandboxResult> RunAsync(SandboxRequest request, CancellationToken ct)
|
||||
{
|
||||
Validate(request);
|
||||
if (request.NeedsLauncher && Helper is null)
|
||||
throw new SandboxUnavailableException("this run needs network or secrets, and the sandbox launcher is not available");
|
||||
|
||||
var filterFile = Path.Combine(Path.GetTempPath(), $"clawddotnet_seccomp_{Guid.NewGuid():N}.bpf");
|
||||
await File.WriteAllBytesAsync(filterFile, SeccompFilter.Build(RuntimeInformation.ProcessArchitecture), ct);
|
||||
// A directory only this user can enter: the filter, the proxy's socket, the secrets.
|
||||
var privateDir = Path.Combine(Path.GetTempPath(), $"clawddotnet_sbx_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(privateDir);
|
||||
if (!OperatingSystem.IsWindows())
|
||||
File.SetUnixFileMode(privateDir, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
|
||||
|
||||
EgressProxy? proxy = null;
|
||||
try
|
||||
{
|
||||
var filterFile = Path.Combine(privateDir, "seccomp.bpf");
|
||||
await File.WriteAllBytesAsync(filterFile, SeccompFilter.Build(RuntimeInformation.ProcessArchitecture), ct);
|
||||
|
||||
var launch = LaunchFiles.None;
|
||||
if (request.Egress.Count > 0)
|
||||
{
|
||||
var socket = Path.Combine(privateDir, "egress.sock");
|
||||
proxy = EgressProxy.Start(socket, request.Egress,
|
||||
LoggerFactory?.CreateLogger<EgressProxy>() ?? (ILogger)Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance);
|
||||
launch = launch with { EgressSocket = socket };
|
||||
}
|
||||
if (request.SecretEnvironment.Count > 0)
|
||||
{
|
||||
var secrets = Path.Combine(privateDir, "env.json");
|
||||
await SandboxLauncher.WriteSecretFileAsync(secrets, request.SecretEnvironment, ct);
|
||||
launch = launch with { SecretFile = secrets };
|
||||
}
|
||||
|
||||
var start = new ProcessStartInfo(Shell)
|
||||
{
|
||||
UseShellExecute = false,
|
||||
@@ -99,14 +133,15 @@ public sealed class BwrapSandbox : ISandboxRunner
|
||||
};
|
||||
// Nothing of the main process's environment — not even to bwrap.
|
||||
start.Environment.Clear();
|
||||
foreach (var argument in CommandLine(_bwrap, filterFile, request, HostLayout.Read(), _features))
|
||||
foreach (var argument in CommandLine(_bwrap, filterFile, request, HostLayout.Read(), _features, Helper, launch))
|
||||
start.ArgumentList.Add(argument);
|
||||
|
||||
return await RunProcessAsync(start, request, ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { File.Delete(filterFile); }
|
||||
proxy?.Dispose();
|
||||
try { Directory.Delete(privateDir, recursive: true); }
|
||||
catch (IOException) { /* in /tmp, harmless */ }
|
||||
}
|
||||
}
|
||||
@@ -116,8 +151,12 @@ public sealed class BwrapSandbox : ISandboxRunner
|
||||
/// Pure — the tests read it on any platform.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> CommandLine(
|
||||
string bwrap, string filterFile, SandboxRequest request, HostLayout host, BwrapFeatures features)
|
||||
string bwrap, string filterFile, SandboxRequest request, HostLayout host, BwrapFeatures features,
|
||||
SandboxHelper? helper = null, LaunchFiles? launch = null)
|
||||
{
|
||||
launch ??= LaunchFiles.None;
|
||||
if (request.NeedsLauncher && helper is null)
|
||||
throw new ArgumentException("This run needs the sandbox launcher.");
|
||||
var limits = request.Limits;
|
||||
var args = new List<string>
|
||||
{
|
||||
@@ -126,7 +165,7 @@ public sealed class BwrapSandbox : ISandboxRunner
|
||||
"""f=$1; ulimit -v "$2" && ulimit -t "$3" && ulimit -f "$4" && ulimit -n "$5" || exit 126; shift 5; exec "$@" 3<"$f" """,
|
||||
"sh",
|
||||
filterFile,
|
||||
Kilobytes(limits.MaxMemoryBytes),
|
||||
limits.MaxMemoryBytes > 0 ? Kilobytes(limits.MaxMemoryBytes) : "unlimited",
|
||||
((long)Math.Ceiling(limits.Timeout.TotalSeconds) + 5).ToString(CultureInfo.InvariantCulture),
|
||||
Blocks(limits.MaxFileBytes),
|
||||
limits.MaxOpenFiles.ToString(CultureInfo.InvariantCulture),
|
||||
@@ -170,6 +209,28 @@ public sealed class BwrapSandbox : ISandboxRunner
|
||||
foreach (var path in request.ReadOnlyPaths)
|
||||
args.AddRange(["--ro-bind", Path.GetFullPath(path), Path.GetFullPath(path)]);
|
||||
|
||||
// #712: a runtime's home, the MCP socket — each at its own place, never inside /work,
|
||||
// where it would stand among the files that come back as the run's result.
|
||||
foreach (var mount in request.Mounts)
|
||||
{
|
||||
if (!mount.Target.StartsWith('/') || mount.Target == SandboxPaths.Work
|
||||
|| mount.Target.StartsWith(SandboxPaths.Work + "/", StringComparison.Ordinal)
|
||||
|| mount.Target.Split('/').Contains(".."))
|
||||
throw new ArgumentException($"'{mount.Target}' is not a place for a mount.");
|
||||
args.AddRange([mount.Writable ? "--bind" : "--ro-bind", Path.GetFullPath(mount.HostPath), mount.Target]);
|
||||
}
|
||||
|
||||
// #712: what the launcher needs — the helper itself, the proxy's socket, the secrets.
|
||||
if (request.NeedsLauncher && helper is not null)
|
||||
{
|
||||
foreach (var path in helper.ReadOnlyPaths)
|
||||
args.AddRange(["--ro-bind", path, path]);
|
||||
if (launch.EgressSocket is { } socket)
|
||||
args.AddRange(["--bind", socket, SandboxPaths.EgressSocket]);
|
||||
if (launch.SecretFile is { } secrets)
|
||||
args.AddRange(["--ro-bind", secrets, SandboxPaths.SecretFile]);
|
||||
}
|
||||
|
||||
// The root bwrap builds is a tmpfs of its own — writable unless it is remounted. Only
|
||||
// /work and /tmp, each a mount of its own, stay writable.
|
||||
args.AddRange(["--remount-ro", "/", "--chdir", SandboxPaths.Work]);
|
||||
@@ -181,6 +242,18 @@ public sealed class BwrapSandbox : ISandboxRunner
|
||||
["LANG"] = "C.UTF-8",
|
||||
["TMPDIR"] = "/tmp"
|
||||
};
|
||||
if (request.NeedsLauncher && helper is not null)
|
||||
{
|
||||
foreach (var (key, value) in helper.Environment)
|
||||
environment[key] = value;
|
||||
if (request.Egress.Count > 0)
|
||||
{
|
||||
// Everything goes to the proxy; the sandbox's loopback has nothing else.
|
||||
var proxy = $"http://127.0.0.1:{SandboxPaths.ProxyPort}";
|
||||
environment["HTTPS_PROXY"] = environment["https_proxy"] = proxy;
|
||||
environment["HTTP_PROXY"] = environment["http_proxy"] = proxy;
|
||||
}
|
||||
}
|
||||
foreach (var (key, value) in request.Environment)
|
||||
environment[key] = value;
|
||||
foreach (var (key, value) in environment.OrderBy(e => e.Key, StringComparer.Ordinal))
|
||||
@@ -192,7 +265,17 @@ public sealed class BwrapSandbox : ISandboxRunner
|
||||
// the process limit, set inside the sandbox's own user namespace. bash calls it -u,
|
||||
// dash (Debian's and Ubuntu's sh) -p; if neither takes, nothing runs.
|
||||
args.AddRange([Shell, "-c", """exec 3<&-; ulimit -u "$1" 2>/dev/null || ulimit -p "$1" || exit 126; shift; exec "$@" """, "sh",
|
||||
limits.MaxProcesses.ToString(CultureInfo.InvariantCulture), request.Executable]);
|
||||
limits.MaxProcesses.ToString(CultureInfo.InvariantCulture)]);
|
||||
if (request.NeedsLauncher && helper is not null)
|
||||
{
|
||||
args.AddRange([helper.Executable, SandboxLauncher.Argument]);
|
||||
if (request.Egress.Count > 0)
|
||||
args.AddRange([SandboxLauncher.EgressOption, SandboxPaths.EgressSocket]);
|
||||
if (request.SecretEnvironment.Count > 0)
|
||||
args.AddRange([SandboxLauncher.SecretsOption, SandboxPaths.SecretFile]);
|
||||
args.Add("--");
|
||||
}
|
||||
args.Add(request.Executable);
|
||||
args.AddRange(request.Arguments);
|
||||
return args;
|
||||
}
|
||||
@@ -457,6 +540,12 @@ internal static class Spawner
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>#712: Files the runner prepared for the launcher, on the host.</summary>
|
||||
public sealed record LaunchFiles(string? EgressSocket, string? SecretFile)
|
||||
{
|
||||
public static LaunchFiles None { get; } = new(null, null);
|
||||
}
|
||||
|
||||
/// <summary>#654: What the installed bwrap can do beyond the basics.</summary>
|
||||
public sealed record BwrapFeatures(bool DisableUserns, bool ClearEnv)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Core.Sandbox;
|
||||
|
||||
/// <summary>
|
||||
/// #712/#655: The only way out of a sandbox — an HTTPS proxy in the main process, reached
|
||||
/// through a Unix socket that is mounted into the sandbox.
|
||||
///
|
||||
/// <para><b>Why not share the network.</b> A sandbox on the host's network sees everything
|
||||
/// the host sees on loopback: the instance's web port, a database, and — per network
|
||||
/// namespace, not per file system — abstract Unix sockets such as an X11 display. The
|
||||
/// sandbox keeps a network of its own with nothing but loopback; the launcher inside
|
||||
/// relays the proxy port there to this socket (<see cref="SandboxLauncher"/>).</para>
|
||||
///
|
||||
/// <para><b>What passes.</b> Only <c>CONNECT host:port</c> — a TLS tunnel, nothing in clear
|
||||
/// — to a host on the list, and only when every address it resolves to is public. A name
|
||||
/// that resolves to loopback or the local network (DNS rebinding) is refused as a whole,
|
||||
/// not filtered address by address. Names are resolved here, never in the sandbox.</para>
|
||||
/// </summary>
|
||||
public sealed class EgressProxy : IDisposable
|
||||
{
|
||||
private const int MaxHeaderBytes = 8192;
|
||||
private const int MaxConnections = 32;
|
||||
private static readonly TimeSpan HeaderTimeout = TimeSpan.FromSeconds(10);
|
||||
private static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(15);
|
||||
|
||||
private readonly Socket _listener;
|
||||
private readonly EgressRules _rules;
|
||||
private readonly ILogger _logger;
|
||||
private readonly CancellationTokenSource _stop = new();
|
||||
private readonly SemaphoreSlim _slots = new(MaxConnections);
|
||||
private readonly Func<string, CancellationToken, Task<IPAddress[]>> _resolve;
|
||||
private readonly Func<IPAddress, bool> _mayConnect;
|
||||
|
||||
private EgressProxy(Socket listener, EgressRules rules, ILogger logger,
|
||||
Func<string, CancellationToken, Task<IPAddress[]>>? resolve, Func<IPAddress, bool>? mayConnect)
|
||||
{
|
||||
_listener = listener;
|
||||
_rules = rules;
|
||||
_logger = logger;
|
||||
_resolve = resolve ?? ((host, ct) => Dns.GetHostAddressesAsync(host, ct));
|
||||
_mayConnect = mayConnect ?? IsPublic;
|
||||
}
|
||||
|
||||
/// <summary>What was refused, for the tests and the log: <c>host:port → reason</c>.</summary>
|
||||
public IReadOnlyCollection<string> Refusals => _refusals;
|
||||
private readonly System.Collections.Concurrent.ConcurrentQueue<string> _refusals = new();
|
||||
|
||||
/// <summary>Starts listening on <paramref name="socketPath"/> (0600).</summary>
|
||||
/// <param name="resolve">Name resolution — the tests' way to a local server.</param>
|
||||
/// <param name="mayConnect">Which addresses may be reached; <see cref="IsPublic"/> unless a test says otherwise.</param>
|
||||
public static EgressProxy Start(string socketPath, IReadOnlyList<string> allowed, ILogger logger,
|
||||
Func<string, CancellationToken, Task<IPAddress[]>>? resolve = null, Func<IPAddress, bool>? mayConnect = null)
|
||||
{
|
||||
var listener = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
|
||||
listener.Bind(new UnixDomainSocketEndPoint(socketPath));
|
||||
if (!OperatingSystem.IsWindows())
|
||||
File.SetUnixFileMode(socketPath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
||||
listener.Listen(MaxConnections);
|
||||
|
||||
var proxy = new EgressProxy(listener, EgressRules.Parse(allowed), logger, resolve, mayConnect);
|
||||
_ = proxy.AcceptLoopAsync();
|
||||
return proxy;
|
||||
}
|
||||
|
||||
private async Task AcceptLoopAsync()
|
||||
{
|
||||
while (!_stop.IsCancellationRequested)
|
||||
{
|
||||
Socket client;
|
||||
try
|
||||
{
|
||||
client = await _listener.AcceptAsync(_stop.Token);
|
||||
}
|
||||
catch (Exception) when (_stop.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!await _slots.WaitAsync(0))
|
||||
{
|
||||
client.Dispose();
|
||||
continue;
|
||||
}
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try { await HandleAsync(client); }
|
||||
catch (Exception ex) when (ex is IOException or SocketException or OperationCanceledException or ObjectDisposedException) { }
|
||||
finally
|
||||
{
|
||||
client.Dispose();
|
||||
_slots.Release();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleAsync(Socket client)
|
||||
{
|
||||
await using var inner = new NetworkStream(client, ownsSocket: false);
|
||||
|
||||
string? head;
|
||||
using (var headTimeout = CancellationTokenSource.CreateLinkedTokenSource(_stop.Token))
|
||||
{
|
||||
headTimeout.CancelAfter(HeaderTimeout);
|
||||
head = await ReadHeadAsync(inner, headTimeout.Token);
|
||||
}
|
||||
if (head is null)
|
||||
return;
|
||||
|
||||
var requestLine = head.Split("\r\n", 2)[0];
|
||||
var parts = requestLine.Split(' ');
|
||||
if (parts.Length != 3 || parts[0] != "CONNECT" || !EgressRules.TrySplit(parts[1], out var host, out var port))
|
||||
{
|
||||
await RefuseAsync(inner, "405 Method Not Allowed", requestLine, "only CONNECT to host:port");
|
||||
return;
|
||||
}
|
||||
|
||||
var target = $"{host}:{port}";
|
||||
if (!_rules.Allows(host, port))
|
||||
{
|
||||
await RefuseAsync(inner, "403 Forbidden", target, "not on the list");
|
||||
return;
|
||||
}
|
||||
|
||||
IPAddress[] addresses;
|
||||
try
|
||||
{
|
||||
addresses = await _resolve(host, _stop.Token);
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
await RefuseAsync(inner, "502 Bad Gateway", target, "does not resolve");
|
||||
return;
|
||||
}
|
||||
if (addresses.Length == 0 || addresses.Any(a => !_mayConnect(a)))
|
||||
{
|
||||
await RefuseAsync(inner, "403 Forbidden", target, "resolves to a non-public address");
|
||||
return;
|
||||
}
|
||||
|
||||
using var outer = new Socket(SocketType.Stream, ProtocolType.Tcp);
|
||||
using (var connectTimeout = CancellationTokenSource.CreateLinkedTokenSource(_stop.Token))
|
||||
{
|
||||
connectTimeout.CancelAfter(ConnectTimeout);
|
||||
try
|
||||
{
|
||||
await outer.ConnectAsync(addresses, port, connectTimeout.Token);
|
||||
}
|
||||
catch (Exception ex) when (ex is SocketException or OperationCanceledException)
|
||||
{
|
||||
await RefuseAsync(inner, "502 Bad Gateway", target, "cannot connect");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Sandbox egress to {Target}", target);
|
||||
await inner.WriteAsync("HTTP/1.1 200 Connection established\r\n\r\n"u8.ToArray(), _stop.Token);
|
||||
await using var remote = new NetworkStream(outer, ownsSocket: false);
|
||||
await PipeAsync(inner, remote, _stop.Token);
|
||||
}
|
||||
|
||||
private async Task RefuseAsync(Stream inner, string status, string target, string reason)
|
||||
{
|
||||
_refusals.Enqueue($"{target} → {reason}");
|
||||
_logger.LogWarning("Sandbox egress refused: {Target} ({Reason})", target, reason);
|
||||
var body = $"Refused by the sandbox: {reason}.\n";
|
||||
await inner.WriteAsync(Encoding.ASCII.GetBytes(
|
||||
$"HTTP/1.1 {status}\r\nContent-Type: text/plain\r\nContent-Length: {body.Length}\r\nConnection: close\r\n\r\n{body}"), _stop.Token);
|
||||
}
|
||||
|
||||
/// <summary>Reads up to the blank line; <c>null</c> if the head is too long or never ends.</summary>
|
||||
private static async Task<string?> ReadHeadAsync(Stream stream, CancellationToken ct)
|
||||
{
|
||||
var buffer = new byte[MaxHeaderBytes];
|
||||
var length = 0;
|
||||
// Byte by byte: whatever follows the head belongs to the tunnel and must not be swallowed.
|
||||
while (length < buffer.Length)
|
||||
{
|
||||
var read = await stream.ReadAsync(buffer.AsMemory(length, 1), ct);
|
||||
if (read == 0)
|
||||
return null;
|
||||
length++;
|
||||
if (length >= 4 && buffer[length - 4] == '\r' && buffer[length - 3] == '\n'
|
||||
&& buffer[length - 2] == '\r' && buffer[length - 1] == '\n')
|
||||
return Encoding.ASCII.GetString(buffer, 0, length);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Both directions until either side ends.</summary>
|
||||
public static async Task PipeAsync(Stream a, Stream b, CancellationToken ct)
|
||||
{
|
||||
using var done = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
var ab = CopyAsync(a, b, done.Token);
|
||||
var ba = CopyAsync(b, a, done.Token);
|
||||
await Task.WhenAny(ab, ba);
|
||||
done.Cancel();
|
||||
try { await Task.WhenAll(ab, ba); }
|
||||
catch (Exception ex) when (ex is IOException or OperationCanceledException or ObjectDisposedException or SocketException) { }
|
||||
}
|
||||
|
||||
private static async Task CopyAsync(Stream from, Stream to, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await from.CopyToAsync(to, 81920, ct);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or OperationCanceledException or ObjectDisposedException or SocketException) { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether an address is on the internet — not loopback, not a private or shared range,
|
||||
/// not link-local (where cloud metadata services live), not multicast or reserved.
|
||||
/// </summary>
|
||||
public static bool IsPublic(IPAddress address)
|
||||
{
|
||||
if (address.IsIPv4MappedToIPv6)
|
||||
address = address.MapToIPv4();
|
||||
|
||||
if (address.AddressFamily == AddressFamily.InterNetwork)
|
||||
{
|
||||
var b = address.GetAddressBytes();
|
||||
return !(b[0] == 0 || b[0] == 10 || b[0] == 127
|
||||
|| (b[0] == 100 && b[1] >= 64 && b[1] <= 127) // shared address space (CGNAT)
|
||||
|| (b[0] == 169 && b[1] == 254) // link-local, metadata
|
||||
|| (b[0] == 172 && b[1] >= 16 && b[1] <= 31)
|
||||
|| (b[0] == 192 && b[1] == 168)
|
||||
|| (b[0] == 192 && b[1] == 0 && b[2] == 0) // IETF protocol assignments
|
||||
|| (b[0] == 198 && (b[1] == 18 || b[1] == 19)) // benchmarking
|
||||
|| b[0] >= 224); // multicast, reserved, broadcast
|
||||
}
|
||||
|
||||
if (address.AddressFamily == AddressFamily.InterNetworkV6)
|
||||
{
|
||||
if (IPAddress.IsLoopback(address) || address.Equals(IPAddress.IPv6None)
|
||||
|| address.IsIPv6LinkLocal || address.IsIPv6SiteLocal || address.IsIPv6Multicast)
|
||||
return false;
|
||||
var b = address.GetAddressBytes();
|
||||
if ((b[0] & 0xFE) == 0xFC) // unique local fc00::/7
|
||||
return false;
|
||||
if (b[0] == 0x00 && b.Take(10).All(x => x == 0)) // unspecified, v4-compatible
|
||||
return false;
|
||||
if (b[0] == 0x20 && b[1] == 0x02) // 6to4 wraps any v4 address
|
||||
return false;
|
||||
if (b[0] == 0x00 && b[1] == 0x64 && b[2] == 0xFF && b[3] == 0x9B) // NAT64
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_stop.Cancel();
|
||||
_listener.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>#712: The hosts a sandbox may reach.</summary>
|
||||
public sealed class EgressRules
|
||||
{
|
||||
private readonly List<(string Host, bool Subdomains, int Port)> _entries;
|
||||
|
||||
private EgressRules(List<(string, bool, int)> entries) => _entries = entries;
|
||||
|
||||
/// <summary><c>host</c>, <c>host:port</c> or <c>*.domain</c>; 443 when no port is given.</summary>
|
||||
public static EgressRules Parse(IEnumerable<string> allowed)
|
||||
{
|
||||
var entries = new List<(string, bool, int)>();
|
||||
foreach (var raw in allowed)
|
||||
{
|
||||
var text = raw.Trim().ToLowerInvariant();
|
||||
if (text.Length == 0)
|
||||
continue;
|
||||
var port = 443;
|
||||
if (TrySplit(text, out var h, out var p))
|
||||
(text, port) = (h, p);
|
||||
var subdomains = text.StartsWith("*.", StringComparison.Ordinal);
|
||||
var host = subdomains ? text[2..] : text;
|
||||
if (host.Length == 0 || host.Contains('*') || host.Contains('/'))
|
||||
throw new ArgumentException($"'{raw}' is not a host.");
|
||||
entries.Add((host, subdomains, port));
|
||||
}
|
||||
return new EgressRules(entries);
|
||||
}
|
||||
|
||||
public bool Allows(string host, int port)
|
||||
{
|
||||
host = host.Trim().TrimEnd('.').ToLowerInvariant();
|
||||
// An address literal is never on the list — the list names hosts, and resolving
|
||||
// happens here, where it is checked.
|
||||
if (IPAddress.TryParse(host.Trim('[', ']'), out _))
|
||||
return false;
|
||||
return _entries.Any(e => e.Port == port
|
||||
&& (e.Subdomains
|
||||
// *.example.org is its subdomains, not example.org itself — say both if both are meant.
|
||||
? host.EndsWith("." + e.Host, StringComparison.Ordinal)
|
||||
: host == e.Host));
|
||||
}
|
||||
|
||||
public static bool TrySplit(string authority, out string host, out int port)
|
||||
{
|
||||
host = "";
|
||||
port = 0;
|
||||
var colon = authority.LastIndexOf(':');
|
||||
if (colon <= 0 || authority.EndsWith(']'))
|
||||
return false;
|
||||
if (!int.TryParse(authority[(colon + 1)..], System.Globalization.NumberStyles.None,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out port) || port is < 1 or > 65535)
|
||||
return false;
|
||||
host = authority[..colon];
|
||||
return host.Length > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ClawdDotNet.Core.Sandbox;
|
||||
|
||||
/// <summary>
|
||||
/// #712: Runs <i>inside</i> the sandbox, in front of the program:
|
||||
/// <c>ClawdDotNet --sandbox-launch [--egress SOCKET] [--secrets FILE] -- PROGRAM ARGS…</c>.
|
||||
///
|
||||
/// <para><b>Network.</b> The sandbox has only loopback. The launcher listens on
|
||||
/// <c>127.0.0.1:</c><see cref="SandboxPaths.ProxyPort"/> and carries every connection to the
|
||||
/// <see cref="EgressProxy"/> socket; the program finds it through <c>HTTPS_PROXY</c>. It
|
||||
/// starts the program only once it listens, so the first request never finds nobody there.</para>
|
||||
///
|
||||
/// <para><b>Secrets.</b> A key read from a file the runner mounted, set in the program's
|
||||
/// environment — a command line would show it to every process of the machine. The
|
||||
/// program can read the file, too: it is its own key, and it has to use it.</para>
|
||||
///
|
||||
/// <para>The program inherits stdin, stdout and stderr; its exit code is the launcher's.</para>
|
||||
/// </summary>
|
||||
public static class SandboxLauncher
|
||||
{
|
||||
public const string Argument = "--sandbox-launch";
|
||||
public const string EgressOption = "--egress";
|
||||
public const string SecretsOption = "--secrets";
|
||||
|
||||
private const int ExitUsage = 125;
|
||||
private const int ExitNotStarted = 127;
|
||||
|
||||
/// <summary>The secrets as the launcher reads them: a JSON object, readable only by its owner.</summary>
|
||||
public static async Task WriteSecretFileAsync(string path, IReadOnlyDictionary<string, string> secrets, CancellationToken ct)
|
||||
{
|
||||
var options = new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write };
|
||||
if (!OperatingSystem.IsWindows())
|
||||
options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite;
|
||||
await using var stream = new FileStream(path, options);
|
||||
await JsonSerializer.SerializeAsync(stream, secrets, cancellationToken: ct);
|
||||
}
|
||||
|
||||
public sealed record Options(string? EgressSocket, string? SecretFile, string Program, IReadOnlyList<string> Arguments);
|
||||
|
||||
/// <summary>Reads the command line after <see cref="Argument"/>; <c>null</c> if it makes no sense.</summary>
|
||||
public static Options? Parse(IReadOnlyList<string> args)
|
||||
{
|
||||
string? egress = null, secrets = null;
|
||||
var i = 0;
|
||||
while (i < args.Count && args[i] != "--")
|
||||
{
|
||||
switch (args[i])
|
||||
{
|
||||
case Argument: i++; break;
|
||||
case EgressOption when i + 1 < args.Count: egress = args[i + 1]; i += 2; break;
|
||||
case SecretsOption when i + 1 < args.Count: secrets = args[i + 1]; i += 2; break;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
if (i + 1 >= args.Count)
|
||||
return null;
|
||||
return new Options(egress, secrets, args[i + 1], args.Skip(i + 2).ToList());
|
||||
}
|
||||
|
||||
public static async Task<int> RunAsync(IReadOnlyList<string> args)
|
||||
{
|
||||
if (Parse(args) is not { } options)
|
||||
{
|
||||
await Console.Error.WriteLineAsync($"Usage: {Argument} [{EgressOption} SOCKET] [{SecretsOption} FILE] -- PROGRAM [ARGS…]");
|
||||
return ExitUsage;
|
||||
}
|
||||
|
||||
using var stop = new CancellationTokenSource();
|
||||
Task? relay = null;
|
||||
if (options.EgressSocket is { } socket)
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, SandboxPaths.ProxyPort);
|
||||
listener.Start();
|
||||
relay = RelayAsync(listener, socket, stop.Token);
|
||||
}
|
||||
|
||||
var start = new ProcessStartInfo(options.Program) { UseShellExecute = false };
|
||||
foreach (var argument in options.Arguments)
|
||||
start.ArgumentList.Add(argument);
|
||||
// The launcher's own tuning is not the program's business.
|
||||
start.Environment.Remove("DOTNET_GCHeapHardLimit");
|
||||
if (options.SecretFile is { } file)
|
||||
{
|
||||
var secrets = JsonSerializer.Deserialize<Dictionary<string, string>>(await File.ReadAllTextAsync(file)) ?? [];
|
||||
foreach (var (key, value) in secrets)
|
||||
start.Environment[key] = value;
|
||||
}
|
||||
|
||||
Process process;
|
||||
try
|
||||
{
|
||||
process = Process.Start(start) ?? throw new InvalidOperationException();
|
||||
}
|
||||
catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException)
|
||||
{
|
||||
await Console.Error.WriteLineAsync($"Cannot start {options.Program}: {ex.Message}");
|
||||
return ExitNotStarted;
|
||||
}
|
||||
|
||||
using (process)
|
||||
{
|
||||
await process.WaitForExitAsync();
|
||||
stop.Cancel();
|
||||
if (relay is not null)
|
||||
await Task.WhenAny(relay, Task.Delay(1000));
|
||||
return process.ExitCode;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Carries each connection on the sandbox's loopback to the proxy socket.</summary>
|
||||
public static async Task RelayAsync(TcpListener listener, string socketPath, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var client = await listener.AcceptTcpClientAsync(ct);
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
using (client)
|
||||
using (var upstream = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified))
|
||||
{
|
||||
try
|
||||
{
|
||||
await upstream.ConnectAsync(new UnixDomainSocketEndPoint(socketPath), ct);
|
||||
await using var upstreamStream = new NetworkStream(upstream, ownsSocket: false);
|
||||
await EgressProxy.PipeAsync(client.GetStream(), upstreamStream, ct);
|
||||
}
|
||||
catch (Exception ex) when (ex is SocketException or IOException or OperationCanceledException) { }
|
||||
}
|
||||
}, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
finally
|
||||
{
|
||||
listener.Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,75 @@ public sealed record SandboxRequest
|
||||
public SandboxLimits Limits { get; init; } = new();
|
||||
|
||||
public string? StandardInput { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// #712: Further host paths at a place of their own — a runtime's home, the MCP socket.
|
||||
/// Applied after the workspace; a target inside <c>/work</c> is refused.
|
||||
/// </summary>
|
||||
public IReadOnlyList<SandboxMount> Mounts { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// #712/#655: The hosts the run may reach, as <c>host</c> or <c>host:port</c> (443 if
|
||||
/// none), <c>*.example.com</c> for subdomains. Empty: no network at all. Otherwise the
|
||||
/// sandbox still has no network of its own; an HTTPS proxy in the main process opens
|
||||
/// only these, and only to public addresses (<see cref="EgressProxy"/>).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Egress { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// #712: Environment the program needs but that must not stand on a command line — an
|
||||
/// API key. Handed over in a file only the sandbox's launcher reads, never through argv.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> SecretEnvironment { get; init; } = new Dictionary<string, string>();
|
||||
|
||||
/// <summary>Whether the run needs the launcher inside the sandbox (<see cref="SandboxLauncher"/>).</summary>
|
||||
public bool NeedsLauncher => Egress.Count > 0 || SecretEnvironment.Count > 0;
|
||||
}
|
||||
|
||||
/// <summary>#712: A host path mounted at <paramref name="Target"/> in the sandbox.</summary>
|
||||
public sealed record SandboxMount(string HostPath, string Target, bool Writable = false);
|
||||
|
||||
/// <summary>
|
||||
/// #712: The program that starts the work inside the sandbox when it needs more than an
|
||||
/// <c>exec</c> — the application itself, called with <see cref="SandboxLauncher.Argument"/>.
|
||||
/// It is mounted read-only with what it needs to start.
|
||||
/// </summary>
|
||||
public sealed record SandboxHelper(
|
||||
string Executable,
|
||||
IReadOnlyList<string> ReadOnlyPaths,
|
||||
IReadOnlyDictionary<string, string> Environment)
|
||||
{
|
||||
/// <summary>
|
||||
/// This application as the helper: its directory, and the .NET it runs on when that is
|
||||
/// not under <c>/usr</c>. <c>null</c> when there is no executable to call (a test host).
|
||||
/// </summary>
|
||||
public static SandboxHelper? ForThisProcess()
|
||||
{
|
||||
var executable = System.Environment.ProcessPath;
|
||||
if (string.IsNullOrEmpty(executable) || !File.Exists(executable))
|
||||
return null;
|
||||
var name = Path.GetFileNameWithoutExtension(executable);
|
||||
return name is "dotnet" or "testhost" ? null : For(executable);
|
||||
}
|
||||
|
||||
/// <summary>The helper at <paramref name="executable"/>, running on the same .NET as this process.</summary>
|
||||
public static SandboxHelper For(string executable)
|
||||
{
|
||||
var paths = new List<string> { Path.GetDirectoryName(Path.GetFullPath(executable))! };
|
||||
var environment = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
// .NET reserves address space by the gigabyte; under a memory limit it would not start.
|
||||
["DOTNET_GCHeapHardLimit"] = "0x20000000",
|
||||
["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1"
|
||||
};
|
||||
// <root>/shared/Microsoft.NETCore.App/<version>/ — the root is three levels up.
|
||||
var runtime = System.Runtime.InteropServices.RuntimeEnvironment.GetRuntimeDirectory();
|
||||
var root = Path.GetFullPath(Path.Combine(runtime, "..", "..", ".."));
|
||||
if (!root.StartsWith("/usr/", StringComparison.Ordinal) && Directory.Exists(root))
|
||||
paths.Add(root);
|
||||
environment["DOTNET_ROOT"] = root;
|
||||
return new SandboxHelper(Path.GetFullPath(executable), paths, environment);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>#654: The limits of one run.</summary>
|
||||
@@ -41,7 +110,10 @@ public sealed record SandboxLimits
|
||||
{
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>Address space per process.</summary>
|
||||
/// <summary>
|
||||
/// Address space per process; 0 for none. A JavaScript runtime (Claude Code) reserves
|
||||
/// tens of gigabytes it never uses and does not start under any sensible limit.
|
||||
/// </summary>
|
||||
public long MaxMemoryBytes { get; init; } = 2L * 1024 * 1024 * 1024;
|
||||
|
||||
/// <summary>Processes and threads the run may have at once — a fork bomb stops here.</summary>
|
||||
@@ -109,6 +181,14 @@ public static class SandboxPaths
|
||||
{
|
||||
public const string Work = "/work";
|
||||
|
||||
/// <summary>#712: Where the launcher finds what the runner hands it.</summary>
|
||||
public const string Run = "/run/clawd";
|
||||
public const string EgressSocket = Run + "/egress.sock";
|
||||
public const string SecretFile = Run + "/env.json";
|
||||
|
||||
/// <summary>#712: The proxy's port on the sandbox's own loopback.</summary>
|
||||
public const int ProxyPort = 3128;
|
||||
|
||||
/// <summary>
|
||||
/// In the workspace, read-only by default: <c>scripts/</c> holds approved scripts and their
|
||||
/// manifests (#695) — what is checked must stay what runs.
|
||||
|
||||
Reference in New Issue
Block a user