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
@@ -8,7 +8,7 @@ using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Tools.SocialMediaManager;
public sealed class SocialMediaManagerTool : IAgentTool, IToolJobProvider
public sealed class SocialMediaManagerTool : IAgentTool, IToolJobProvider, ClawdDotNet.Core.Sandbox.ISandboxUser
{
public string Name => "SocialMediaManager";
@@ -523,7 +523,7 @@ public sealed class SocialMediaManagerTool : IAgentTool, IToolJobProvider
logger.LogInformation("Starting transcription for {File} ({Size:F1} MB) via OpenRouter ({Model}), ffmpeg={HasFfmpeg}",
Path.GetFileName(audioFile), fileSize / 1024.0 / 1024.0, model, hasFfmpeg);
var transcript = await TranscribeAudioAsync(audioFile, apiKey, model, hasFfmpeg, ffmpegPath, ytDlpPath, logger, ct);
var transcript = await TranscribeAudioAsync(audioFile, apiKey, model, hasFfmpeg, ffmpegPath, ytDlpPath, logger, ct, workspacePath);
// 4. Save transcript
var transcriptFile = Path.Combine(targetDir, $"{videoId}.txt");
@@ -574,8 +574,13 @@ public sealed class SocialMediaManagerTool : IAgentTool, IToolJobProvider
private const long MaxChunkBytes = 24 * 1024 * 1024; // 24 MB (Whisper-Limit: 25 MB, Puffer)
/// <summary>#654: Where ffmpeg runs — set by the instance at start.</summary>
public ClawdDotNet.Core.Sandbox.ISandboxRunner? Sandbox { get; set; }
private int _warnedUnsandboxed;
private async Task<string> TranscribeAudioAsync(string filePath, string apiKey, string model,
bool hasFfmpeg, string? ffmpegPath, string ytDlpPath, ILogger logger, CancellationToken ct)
bool hasFfmpeg, string? ffmpegPath, string ytDlpPath, ILogger logger, CancellationToken ct, string? workspacePath = null)
{
var fileInfo = new FileInfo(filePath);
if (!fileInfo.Exists)
@@ -596,8 +601,45 @@ public sealed class SocialMediaManagerTool : IAgentTool, IToolJobProvider
{
List<string> chunks;
if (hasFfmpeg)
if (hasFfmpeg && workspacePath is not null && Sandbox is { IsAvailable: true } sandbox
&& ClawdDotNet.Core.Sandbox.SandboxPaths.InSandbox(workspacePath, filePath) is { } inFile
&& ClawdDotNet.Core.Sandbox.SandboxPaths.InSandbox(workspacePath, chunkDir) is { } inChunks)
{
// #654: The media comes from the internet — ffmpeg parses it in the sandbox,
// seeing nothing but the workspace, without network.
var ffmpegExe = !string.IsNullOrEmpty(ffmpegPath) ? ffmpegPath : "ffmpeg";
var result = await sandbox.RunAsync(new ClawdDotNet.Core.Sandbox.SandboxRequest
{
Executable = ffmpegExe,
Arguments = ["-hide_banner", "-loglevel", "error", "-i", inFile, "-f", "segment", "-segment_time", "1200",
"-c:a", "libmp3lame", "-q:a", "5", inChunks + "/chunk_%03d.mp3"],
WorkspacePath = workspacePath,
// A program outside /usr is made visible where it lies — read-only.
ReadOnlyPaths = Path.IsPathRooted(ffmpegExe) && !ffmpegExe.StartsWith("/usr/", StringComparison.Ordinal)
? [Path.GetDirectoryName(ffmpegExe)!]
: [],
Limits = new ClawdDotNet.Core.Sandbox.SandboxLimits
{
Timeout = TimeSpan.FromMinutes(30),
MaxFileBytes = 200L * 1024 * 1024,
MaxOutputBytes = 256 * 1024
}
}, ct);
if (!result.Succeeded)
logger.LogWarning("ffmpeg in the sandbox ended with {Code}{Timeout}: {Error}",
result.ExitCode, result.TimedOut ? " (timeout)" : "", result.StandardError.Trim());
chunks = Directory.GetFiles(chunkDir, "chunk_*.mp3").OrderBy(f => f).ToList();
}
else if (hasFfmpeg)
{
// #654: Without a sandbox (Windows, or no bwrap) as before — said once. yt-dlp,
// which needs the network, still runs this way everywhere until #655 brings the
// sandbox a network of its own.
if (OperatingSystem.IsLinux() && Interlocked.Exchange(ref _warnedUnsandboxed, 1) == 0)
logger.LogWarning("ffmpeg runs without a sandbox: {Reason}",
Sandbox?.UnavailableReason ?? "no sandbox was handed to the tool");
// ffmpeg: 20-minute segments as mp3 (under 25 MB Whisper limit at q:a 5)
var chunkPattern = Path.Combine(chunkDir, "chunk_%03d.mp3");
var ffmpegExe = !string.IsNullOrEmpty(ffmpegPath) ? ffmpegPath : "ffmpeg";