Sandbox-Runner mit bubblewrap (#654) - noch ungeprueft unter Linux

Jede Codeausfuehrung ausserhalb des Hauptprozesses: eigene Namensraeume
(Mount, PID, Netz nur loopback, User ohne verschachtelte), nur der
Arbeitsbereich unter /work beschreibbar, scripts/ darin schreibgeschuetzt,
keine Umgebung und keine Deskriptoren des Hauptprozesses, alle Capabilities
weg, no_new_privs, seccomp-Sperrliste (eigene BPF, x86_64 und arm64),
Grenzen fuer Zeit, Speicher, Prozesse, Dateigroesse und Ausgabe. Unter
Windows oder ohne bwrap: keine Ausfuehrung, mit Grund im Protokoll.

Erster Nutzer: ffmpeg im SocialMediaManager (fremde Mediendateien). yt-dlp
braucht Netz und folgt mit dem Egress-Proxy (#655).

Die Ausbruchstests (SandboxEscapeTests) laufen nur unter Linux mit bwrap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-25 18:40:42 +02:00
co-authored by Claude Opus 5.5
parent 8c5eca18f5
commit 257ba87cf5
9 changed files with 1096 additions and 4 deletions
@@ -0,0 +1,129 @@
namespace ClawdDotNet.Core.Sandbox;
/// <summary>
/// #654: What may run in the sandbox, and what it may see.
///
/// <para><b>Only the workspace is there.</b> It is mounted at <see cref="SandboxPaths.Work"/>
/// and is the only writable place besides a private <c>/tmp</c>. The system is read-only;
/// <c>$HOME</c>, the instance directory, the key ring and other agents' workspaces do not
/// exist in the sandbox — not blocked, absent.</para>
/// </summary>
public sealed record SandboxRequest
{
/// <summary>The program, as the sandbox sees it: <c>ffmpeg</c> (from the system's PATH) or an absolute path.</summary>
public required string Executable { get; init; }
public IReadOnlyList<string> Arguments { get; init; } = [];
/// <summary>The agent's workspace on the host — mounted writable at <c>/work</c>.</summary>
public required string WorkspacePath { get; init; }
/// <summary>
/// Paths inside the workspace (relative) that the sandbox may read but not write —
/// whatever the main process reads as configuration or instruction. A sandbox that may
/// write what is later executed or obeyed outside is a delayed escape (CVE-2026-25725).
/// </summary>
public IReadOnlyList<string> ProtectedPaths { get; init; } = SandboxPaths.DefaultProtected;
/// <summary>Further host paths made visible read-only, at the same path — e.g. a program outside /usr.</summary>
public IReadOnlyList<string> ReadOnlyPaths { get; init; } = [];
/// <summary>The environment — only this. Nothing of the main process's is inherited.</summary>
public IReadOnlyDictionary<string, string> Environment { get; init; } = new Dictionary<string, string>();
public SandboxLimits Limits { get; init; } = new();
public string? StandardInput { get; init; }
}
/// <summary>#654: The limits of one run.</summary>
public sealed record SandboxLimits
{
public TimeSpan Timeout { get; init; } = TimeSpan.FromMinutes(5);
/// <summary>Address space per process.</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>
public int MaxProcesses { get; init; } = 64;
/// <summary>The largest file the run may write.</summary>
public long MaxFileBytes { get; init; } = 1024L * 1024 * 1024;
/// <summary>Stdout and stderr together; beyond it the run is stopped.</summary>
public int MaxOutputBytes { get; init; } = 1024 * 1024;
public int MaxOpenFiles { get; init; } = 256;
}
public sealed record SandboxResult
{
public int ExitCode { get; init; }
public string StandardOutput { get; init; } = "";
public string StandardError { get; init; } = "";
public bool TimedOut { get; init; }
public bool OutputTruncated { get; init; }
public bool Succeeded => ExitCode == 0 && !TimedOut && !OutputTruncated;
}
/// <summary>
/// #654: Runs code outside the main process. The one way code runs — shell (#196), approved
/// scripts, MCP stdio servers (#197), the media tools.
/// </summary>
public interface ISandboxRunner
{
/// <summary>Whether code can run at all. Without a sandbox it does not — the function is missing, not weaker.</summary>
bool IsAvailable { get; }
/// <summary>Why not, if <see cref="IsAvailable"/> is false.</summary>
string? UnavailableReason { get; }
Task<SandboxResult> RunAsync(SandboxRequest request, CancellationToken ct);
}
/// <summary>
/// #654: A tool that runs programs — it is handed the instance's sandbox at start. Also for
/// work that runs without a tool context (jobs), where <c>AgentToolContext.Sandbox</c> is not there.
/// </summary>
public interface ISandboxUser
{
ISandboxRunner? Sandbox { get; set; }
}
/// <summary>#654: Where there is no sandbox (Windows, or no bwrap): nothing runs.</summary>
public sealed class NoSandbox(string reason) : ISandboxRunner
{
public bool IsAvailable => false;
public string? UnavailableReason => reason;
public Task<SandboxResult> RunAsync(SandboxRequest request, CancellationToken ct)
=> throw new SandboxUnavailableException(reason);
}
public sealed class SandboxUnavailableException(string reason)
: InvalidOperationException($"Code execution is not available: {reason}");
/// <summary>#654: Paths as the sandbox sees them.</summary>
public static class SandboxPaths
{
public const string Work = "/work";
/// <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.
/// </summary>
public static readonly IReadOnlyList<string> DefaultProtected = ["scripts"];
/// <summary>A host path inside the workspace, as the sandbox sees it; <c>null</c> if it lies outside.</summary>
public static string? InSandbox(string workspace, string hostPath)
{
var root = Path.GetFullPath(workspace).TrimEnd('/', '\\');
var full = Path.GetFullPath(hostPath);
if (string.Equals(full.TrimEnd('/', '\\'), root, StringComparison.Ordinal))
return Work;
if (!full.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.Ordinal))
return null;
return Work + "/" + full[(root.Length + 1)..].Replace('\\', '/');
}
}