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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user