Merge feat/654-sandbox: Sandbox-Runner mit bubblewrap (#654)

Unter Linux geprueft: 39 Ausbruchstests, viermal die volle Core-Suite
unter Last auf der Test-VM.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-25 21:27:26 +02:00
co-authored by Claude Opus 5.5
10 changed files with 1252 additions and 4 deletions
+23
View File
@@ -81,6 +81,29 @@ Garantien, jede mit einem Test, der den Ausbruch versucht:
Unter Windows gibt es keinen Runner und damit **keine Codeausführung** — die Funktion
fehlt dort, statt schwächer zu sein.
*Gebaut am 2026-09-25 (#654), geprüft unter Ubuntu 26.04 mit bwrap 0.11.1 auf der
Test-VM.* Was beim Bau dazukam und in keinem Handbuch steht:
- **Die Wurzel der Sandbox ist beschreibbar**, wenn man nichts tut: bwrap baut sie als
eigenes tmpfs. Erst `--remount-ro /` nach allen Einbindungen macht daraus „nur `/work`
und `/tmp`“. Der Ausbruchstest hatte unter `/etc` eine Datei angelegt.
- **Der seccomp-Deskriptor wird weitergereicht.** bwrap liest den Filter über fd 3 und
lässt ihn im Programm offen; die innere Shell schließt ihn vor dem Start.
- **Die Prozessgrenze** (RLIMIT_NPROC) wird *innerhalb* des eigenen User-Namensraums
gesetzt. Dort zählt sie nur die Prozesse der Sandbox, nicht die Threads der Instanz
(auf der VM über tausend unter demselben Benutzer). Das ist mit einem Gegenfall
belegt: 5 von 10 gehen, 12 nicht. dash kennt dafür `ulimit -p`, bash `ulimit -u`.
- **`--die-with-parent` hängt am Thread, nicht am Prozess.** Das Elternsterbe-Signal
des Kernels feuert, wenn der *Thread* endet, der den Prozess gestartet hat. .NET
startet aus einem Threadpool-Thread, und unter Last baut der Threadpool Threads ab.
Folge auf der VM: bwrap bekam mitten im Lauf SIGKILL, und starb er noch im Aufbau,
lebte das Innere als Waise weiter. Alle Sandboxen starten deshalb aus einem eigenen,
dauerhaften Thread. Zum Beenden wird der ganze Baum unter bwrap aus `/proc`
eingesammelt und getötet, samt PID 1 des Namensraums, dessen Tod den Rest mitnimmt.
- **Erster Nutzer ist ffmpeg** im SocialMediaManager, weil es heruntergeladene Medien
zerlegt. yt-dlp braucht Netz und kommt mit dem Egress-Proxy (#655) in die Sandbox; bis
dahin läuft es wie bisher und das Protokoll sagt es.
## 4 — Schicht 1: Regeln vor dem Aufruf
Vorhanden: PermissionGate (Werkzeug je Agent), Staging mit Plan-Freeze, `PathBoundary`,
+9
View File
@@ -589,6 +589,15 @@ public sealed class AppHost : IAsyncDisposable
SpecialBudgets = specialBudgets
};
// #654: Code runs only in the sandbox. Found once at start; without it, nothing that
// needs it runs — and the log says why.
var sandbox = Core.Sandbox.BwrapSandbox.Detect(loggerFactory.CreateLogger("ClawdDotNet.Core.Sandbox"));
if (!sandbox.IsAvailable)
logger.LogWarning("No sandbox: {Reason}", sandbox.UnavailableReason);
engine.Sandbox = sandbox;
foreach (var user in tools.GetAll().OfType<Core.Sandbox.ISandboxUser>())
user.Sandbox = sandbox;
engine.SetAgentConfigProvider(
() => instance.Agents,
instance.InstanceId,
@@ -60,6 +60,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
/// </summary>
public Budget.ISpecialBudgetRepository? SpecialBudgets { get; set; }
/// <summary>#654: The sandbox tools run code in.</summary>
public Sandbox.ISandboxRunner? Sandbox { get; set; }
private Organisation.OrgChart CurrentOrganisation()
=> OrganisationProvider?.Invoke() ?? new Organisation.OrgChart([], _agentConfigProvider?.Invoke() ?? []);
@@ -1764,6 +1767,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
Trust = ReportingLine.TrustOf(agentConfig, _toolRegistry.Get),
Cases = CaseDesk,
SpecialBudgets = SpecialBudgets,
Sandbox = Sandbox,
// #697: Where something an agent hands on came from — known to the engine, not claimed by the agent.
RunId = runId,
RunTaskId = runId is null ? null : Activity.Find(runId)?.TaskId
@@ -0,0 +1,491 @@
using System.Diagnostics;
using System.Globalization;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Core.Sandbox;
/// <summary>
/// #654: The sandbox under Linux, built on bubblewrap.
///
/// <para><b>What holds, and where it comes from</b> (each with a test that tries to break it):</para>
/// <list type="bullet">
/// <item><b>File system:</b> own mount namespace. <c>/usr</c> and the few files of
/// <c>/etc</c> a program needs are mounted read-only; the workspace at <c>/work</c> is the
/// only writable place besides a private <c>/tmp</c>. <c>$HOME</c>, the instance, the key
/// ring and other workspaces are not mounted at all.</item>
/// <item><b>Inheritance:</b> the environment of bwrap itself is empty, the sandbox gets only
/// what the request names. .NET opens its files close-on-exec; the sandbox sees fds 0–2.</item>
/// <item><b>Network:</b> own network namespace with nothing but loopback.</item>
/// <item><b>Limits:</b> wall-clock timeout (the whole namespace dies with bwrap), address
/// space, CPU time, file size, open files — as rlimits; processes as RLIMIT_NPROC, set
/// inside the sandbox's own user namespace, where it counts the sandbox's processes and not
/// the instance's threads. Output is capped while it is read.</item>
/// <item><b>Privileges:</b> all capabilities dropped, <c>no_new_privs</c> (bwrap sets it
/// always), no nested user namespaces where bwrap can refuse them, and the
/// <see cref="SeccompFilter"/>.</item>
/// </list>
/// </summary>
public sealed class BwrapSandbox : ISandboxRunner
{
private const string Shell = "/bin/sh";
private readonly string _bwrap;
private readonly BwrapFeatures _features;
private readonly ILogger _logger;
private BwrapSandbox(string bwrap, BwrapFeatures features, ILogger logger)
{
_bwrap = bwrap;
_features = features;
_logger = logger;
}
public bool IsAvailable => true;
public string? UnavailableReason => null;
/// <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
/// trying), otherwise <see cref="NoSandbox"/> with the reason.
/// </summary>
public static ISandboxRunner Detect(ILogger logger)
{
if (!OperatingSystem.IsLinux())
return new NoSandbox("code runs only under Linux, in a bubblewrap sandbox");
if (!SeccompFilter.Supports(RuntimeInformation.ProcessArchitecture))
return new NoSandbox($"no seccomp filter for {RuntimeInformation.ProcessArchitecture}");
var bwrap = new[] { "/usr/bin/bwrap", "/bin/bwrap", "/usr/local/bin/bwrap" }.FirstOrDefault(File.Exists);
if (bwrap is null)
return new NoSandbox("bubblewrap (bwrap) is not installed — 'apt install bubblewrap'");
var help = RunQuiet(bwrap, ["--help"]);
// The probe mounts the system exactly as a real run does — a missing /lib64 (the
// dynamic loader) looks like "no such file" and would pass for a broken sandbox.
var probeArgs = new List<string> { "--unshare-all", "--die-with-parent", "--ro-bind", "/usr", "/usr" };
foreach (var (path, link) in HostLayout.Read().TopLevel)
probeArgs.AddRange(link is not null ? ["--symlink", link, path] : ["--ro-bind-try", path, path]);
probeArgs.AddRange(["--proc", "/proc", "--dev", "/dev", "/usr/bin/true"]);
var probe = RunQuiet(bwrap, probeArgs);
if (probe.ExitCode != 0)
return new NoSandbox($"bwrap cannot create a sandbox here ({probe.Output.Trim()}) — user namespaces may be disabled");
var features = new BwrapFeatures(
DisableUserns: help.Output.Contains("--disable-userns", StringComparison.Ordinal),
ClearEnv: help.Output.Contains("--clearenv", StringComparison.Ordinal));
logger.LogInformation("Sandbox ready: {Bwrap}{Userns}", bwrap,
features.DisableUserns ? "" : " (older bwrap: nested user namespaces are refused by the seccomp filter only)");
return new BwrapSandbox(bwrap, features, logger);
}
public async Task<SandboxResult> RunAsync(SandboxRequest request, CancellationToken ct)
{
Validate(request);
var filterFile = Path.Combine(Path.GetTempPath(), $"clawddotnet_seccomp_{Guid.NewGuid():N}.bpf");
await File.WriteAllBytesAsync(filterFile, SeccompFilter.Build(RuntimeInformation.ProcessArchitecture), ct);
try
{
var start = new ProcessStartInfo(Shell)
{
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
};
// 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))
start.ArgumentList.Add(argument);
return await RunProcessAsync(start, request, ct);
}
finally
{
try { File.Delete(filterFile); }
catch (IOException) { /* in /tmp, harmless */ }
}
}
/// <summary>
/// The whole command line: <c>/bin/sh -c WRAPPER sh FILTER LIMITS… bwrap ARGS… -- sh -c INNER sh NPROC PROGRAM ARGS…</c>.
/// Pure — the tests read it on any platform.
/// </summary>
public static IReadOnlyList<string> CommandLine(
string bwrap, string filterFile, SandboxRequest request, HostLayout host, BwrapFeatures features)
{
var limits = request.Limits;
var args = new List<string>
{
"-c",
// Outer: rlimits that bind the sandbox as a whole, then bwrap with the filter on fd 3.
"""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),
((long)Math.Ceiling(limits.Timeout.TotalSeconds) + 5).ToString(CultureInfo.InvariantCulture),
Blocks(limits.MaxFileBytes),
limits.MaxOpenFiles.ToString(CultureInfo.InvariantCulture),
bwrap,
"--unshare-all",
"--unshare-user",
"--die-with-parent",
"--new-session",
"--cap-drop", "ALL",
"--hostname", "sandbox"
};
// bwrap's own environment is already empty; where it can, it says so once more.
if (features.ClearEnv)
args.Add("--clearenv");
if (features.DisableUserns)
args.Add("--disable-userns");
// The system, read-only. Merged /usr: /bin, /lib … are links into it.
args.AddRange(["--ro-bind", "/usr", "/usr"]);
foreach (var (path, link) in host.TopLevel)
{
if (link is not null)
args.AddRange(["--symlink", link, path]);
else
args.AddRange(["--ro-bind-try", path, path]);
}
foreach (var etc in EtcFiles)
args.AddRange(["--ro-bind-try", etc, etc]);
args.AddRange(["--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", "--dir", "/run"]);
// The workspace, and inside it what must stay as it was checked.
var workspace = Path.GetFullPath(request.WorkspacePath);
args.AddRange(["--bind", workspace, SandboxPaths.Work]);
foreach (var relative in request.ProtectedPaths)
{
var hostPath = Path.GetFullPath(Path.Combine(workspace, relative));
if (SandboxPaths.InSandbox(workspace, hostPath) is { } inside && inside != SandboxPaths.Work)
args.AddRange(["--ro-bind-try", hostPath, inside]);
}
foreach (var path in request.ReadOnlyPaths)
args.AddRange(["--ro-bind", Path.GetFullPath(path), Path.GetFullPath(path)]);
// 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]);
var environment = new Dictionary<string, string>(StringComparer.Ordinal)
{
["PATH"] = "/usr/local/bin:/usr/bin:/bin",
["HOME"] = "/tmp",
["LANG"] = "C.UTF-8",
["TMPDIR"] = "/tmp"
};
foreach (var (key, value) in request.Environment)
environment[key] = value;
foreach (var (key, value) in environment.OrderBy(e => e.Key, StringComparer.Ordinal))
args.AddRange(["--setenv", key, value]);
args.AddRange(["--seccomp", "3", "--"]);
// Inner: fd 3 — the filter bwrap read — is closed; bwrap hands it on otherwise. Then
// 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]);
args.AddRange(request.Arguments);
return args;
}
/// <summary>What a program commonly needs of <c>/etc</c> — and nothing that says who uses this machine.</summary>
private static readonly string[] EtcFiles =
[
"/etc/ld.so.cache", "/etc/ld.so.conf", "/etc/ld.so.conf.d", "/etc/alternatives",
"/etc/localtime", "/etc/ssl/certs", "/etc/ca-certificates", "/etc/fonts", "/etc/mime.types"
];
/// <summary>The workspace must exist and must not be a place that contains everything else.</summary>
private static void Validate(SandboxRequest request)
{
var workspace = Path.GetFullPath(request.WorkspacePath).TrimEnd('/');
if (!Directory.Exists(workspace))
throw new ArgumentException($"The workspace '{workspace}' does not exist.");
var home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile).TrimEnd('/');
if (workspace.Length == 0 || workspace == home || workspace.Count(c => c == '/') < 2)
throw new ArgumentException($"'{workspace}' is not a workspace — it would expose far more than one agent's files.");
if (string.IsNullOrWhiteSpace(request.Executable))
throw new ArgumentException("No program given.");
}
private async Task<SandboxResult> RunProcessAsync(ProcessStartInfo start, SandboxRequest request, CancellationToken ct)
{
using var process = await Spawner.StartAsync(start);
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(request.Limits.Timeout);
var output = new CappedOutput(request.Limits.MaxOutputBytes);
var stdout = PumpAsync(process.StandardOutput, output, isError: false, process);
var stderr = PumpAsync(process.StandardError, output, isError: true, process);
try
{
if (request.StandardInput is { } input)
await process.StandardInput.WriteAsync(input.AsMemory(), timeout.Token);
process.StandardInput.Close();
}
catch (IOException)
{
// The program did not read its input — that is its business.
}
var timedOut = false;
try
{
await process.WaitForExitAsync(timeout.Token);
}
catch (OperationCanceledException)
{
timedOut = !ct.IsCancellationRequested;
Kill(process);
if (!timedOut)
throw;
}
// After a stop, the streams end with the processes — but a reader must never hang on a
// pipe something still holds. Bounded, then the result is what arrived.
var pumps = Task.WhenAll(stdout, stderr);
if (timedOut || output.Truncated)
await Task.WhenAny(pumps, Task.Delay(TimeSpan.FromSeconds(5), CancellationToken.None));
else
await pumps;
if (timedOut)
_logger.LogWarning("Sandbox run of {Program} stopped after {Timeout}", request.Executable, request.Limits.Timeout);
return new SandboxResult
{
ExitCode = process.HasExited ? process.ExitCode : -1,
StandardOutput = output.Out,
StandardError = output.Err,
TimedOut = timedOut,
OutputTruncated = output.Truncated
};
}
/// <summary>Reads one stream into the shared cap; past the cap the run is stopped.</summary>
private static async Task PumpAsync(StreamReader reader, CappedOutput output, bool isError, Process process)
{
var buffer = new char[8192];
int read;
while ((read = await reader.ReadAsync(buffer.AsMemory())) > 0)
{
if (!output.Append(buffer.AsSpan(0, read), isError))
{
Kill(process);
return;
}
}
}
/// <summary>
/// Stops the run: everything below bwrap is collected first, then all of it is killed.
///
/// <para><b>Not trusting <c>--die-with-parent</c>.</b> The kernel clears the parent-death
/// signal when a process changes its credentials — and bwrap does exactly that while it
/// builds the user namespace. Seen on the test machine: bwrap's monitor was gone, the
/// sandbox lived on under PID 1 and kept the output pipe open. Killing the sandbox's own
/// PID 1 (the init of its PID namespace) takes every process inside with it.</para>
/// </summary>
private static void Kill(Process process)
{
int root;
try
{
root = process.Id;
}
catch (InvalidOperationException)
{
return;
}
foreach (var pid in Descendants(root).Append(root))
{
try
{
using var victim = Process.GetProcessById(pid);
victim.Kill();
}
catch (ArgumentException) { } // already gone
catch (InvalidOperationException) { }
catch (System.ComponentModel.Win32Exception) { }
}
}
/// <summary>All processes below <paramref name="root"/>, from /proc — collected before anything is killed.</summary>
private static IEnumerable<int> Descendants(int root)
{
var children = new Dictionary<int, List<int>>();
foreach (var dir in Directory.EnumerateDirectories("/proc"))
{
if (!int.TryParse(Path.GetFileName(dir), out var pid))
continue;
try
{
// /proc/<pid>/stat: "pid (comm) state ppid …" — comm may hold spaces and parentheses.
var stat = File.ReadAllText(Path.Combine(dir, "stat"));
var fields = stat[(stat.LastIndexOf(')') + 2)..].Split(' ');
if (int.TryParse(fields[1], out var parent))
{
if (!children.TryGetValue(parent, out var list))
children[parent] = list = [];
list.Add(pid);
}
}
catch (IOException) { } // it ended meanwhile
catch (UnauthorizedAccessException) { }
}
var result = new List<int>();
var queue = new Queue<int>([root]);
while (queue.Count > 0 && result.Count < 10_000)
{
foreach (var child in children.GetValueOrDefault(queue.Dequeue()) ?? [])
{
result.Add(child);
queue.Enqueue(child);
}
}
return result;
}
private static string Kilobytes(long bytes) => Math.Max(1, bytes / 1024).ToString(CultureInfo.InvariantCulture);
/// <summary><c>ulimit -f</c> counts 512-byte blocks in POSIX sh.</summary>
private static string Blocks(long bytes) => Math.Max(1, bytes / 512).ToString(CultureInfo.InvariantCulture);
private static (int ExitCode, string Output) RunQuiet(string program, IReadOnlyList<string> arguments)
{
try
{
var start = new ProcessStartInfo(program)
{
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true
};
start.Environment.Clear();
foreach (var argument in arguments)
start.ArgumentList.Add(argument);
using var process = Process.Start(start)!;
var text = process.StandardOutput.ReadToEnd() + process.StandardError.ReadToEnd();
return process.WaitForExit(5000) ? (process.ExitCode, text) : (-1, "timed out");
}
catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or IOException)
{
return (-1, ex.Message);
}
}
private sealed class CappedOutput(int max)
{
private readonly StringBuilder _out = new();
private readonly StringBuilder _err = new();
private int _total;
public bool Truncated { get; private set; }
public string Out { get { lock (this) return _out.ToString(); } }
public string Err { get { lock (this) return _err.ToString(); } }
public bool Append(ReadOnlySpan<char> text, bool isError)
{
lock (this)
{
var room = max - _total;
var take = Math.Min(room, text.Length);
(isError ? _err : _out).Append(text[..Math.Max(0, take)]);
_total += Math.Max(0, take);
if (take < text.Length)
Truncated = true;
return !Truncated;
}
}
}
}
/// <summary>
/// #654: Starts every sandbox from one thread that lives as long as the process.
///
/// <para><b>Why.</b> <c>--die-with-parent</c> uses the kernel's parent-death signal, and that
/// is bound to the <i>thread</i> that forked, not to the process. .NET forks on the calling
/// thread — a thread-pool thread, and the pool retires idle threads. Seen on the test machine
/// under load: the pool dropped a thread, bwrap got SIGKILL (exit 137), and when it happened
/// while bwrap was still building the sandbox, the inside lived on as an orphan holding the
/// output pipes. Started from a thread that never ends, the signal fires only when the
/// instance itself dies — which is what it is for.</para>
/// </summary>
internal static class Spawner
{
private static readonly System.Collections.Concurrent.BlockingCollection<(ProcessStartInfo Start, TaskCompletionSource<Process> Done)> Queue = new();
static Spawner()
{
var thread = new Thread(() =>
{
foreach (var (start, done) in Queue.GetConsumingEnumerable())
{
try
{
done.SetResult(Process.Start(start) ?? throw new InvalidOperationException("The sandbox did not start."));
}
catch (Exception ex)
{
done.SetException(ex);
}
}
})
{
IsBackground = true,
Name = "ClawdDotNet sandbox spawner"
};
thread.Start();
}
public static Task<Process> StartAsync(ProcessStartInfo start)
{
var done = new TaskCompletionSource<Process>(TaskCreationOptions.RunContinuationsAsynchronously);
Queue.Add((start, done));
return done.Task;
}
}
/// <summary>#654: What the installed bwrap can do beyond the basics.</summary>
public sealed record BwrapFeatures(bool DisableUserns, bool ClearEnv)
{
public static BwrapFeatures All { get; } = new(true, true);
}
/// <summary>
/// #654: How the top level of this machine looks — which of <c>/bin</c>, <c>/lib</c> … are
/// links into <c>/usr</c> (merged /usr) and which are directories. Read once per run; a
/// record, so the tests can describe any machine.
/// </summary>
public sealed record HostLayout(IReadOnlyList<(string Path, string? Link)> TopLevel)
{
private static readonly string[] Candidates = ["/bin", "/sbin", "/lib", "/lib64", "/lib32", "/libx32"];
public static HostLayout Read()
{
var entries = new List<(string, string?)>();
foreach (var path in Candidates)
{
var info = new FileInfo(path);
if (info.LinkTarget is { } target)
entries.Add((path, target.TrimStart('/')));
else if (Directory.Exists(path))
entries.Add((path, null));
}
return new HostLayout(entries);
}
/// <summary>A merged-/usr system, as Debian and Ubuntu are today.</summary>
public static HostLayout MergedUsr { get; } = new([("/bin", "usr/bin"), ("/sbin", "usr/sbin"), ("/lib", "usr/lib"), ("/lib64", "usr/lib64")]);
}
@@ -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('\\', '/');
}
}
@@ -0,0 +1,126 @@
using System.Runtime.InteropServices;
namespace ClawdDotNet.Core.Sandbox;
/// <summary>
/// #654: The seccomp filter of the sandbox — a classic BPF program that bwrap installs
/// before the program starts (<c>--seccomp</c>).
///
/// <para><b>A deny list, deliberately.</b> An allow list for every program that may one day
/// run here (ffmpeg, Python, a shell) is a maintenance job of its own and breaks at the
/// first new syscall of a library. The namespaces are the wall; this filter takes away the
/// tools for climbing it: kernel interfaces that were the way out of container after
/// container — mounting, new namespaces, ptrace, keyrings, BPF, io_uring, userfaultfd,
/// perf, modules. Refused with <c>EPERM</c>, not killed: a program that probes for a
/// feature then falls back instead of dying mysteriously.</para>
///
/// <para>A foreign architecture is killed outright — otherwise the 32-bit syscall numbers
/// would slip through a filter written for 64-bit ones. On x86_64 the x32 ABI is refused
/// for the same reason.</para>
/// </summary>
public static class SeccompFilter
{
private const ushort LdWAbs = 0x20; // BPF_LD | BPF_W | BPF_ABS
private const ushort JeqK = 0x15; // BPF_JMP | BPF_JEQ | BPF_K
private const ushort JgeK = 0x35; // BPF_JMP | BPF_JGE | BPF_K
private const ushort RetK = 0x06; // BPF_RET | BPF_K
private const uint KillProcess = 0x80000000;
private const uint Allow = 0x7fff0000;
private const uint ErrnoEperm = 0x00050000 | 1;
private const uint ArchX8664 = 0xC000003E;
private const uint ArchAarch64 = 0xC00000B7;
private const uint X32Bit = 0x40000000;
/// <summary>The refused calls, by name — the numbers differ by architecture.</summary>
public static readonly IReadOnlyList<string> Refused =
[
"ptrace", "process_vm_readv", "process_vm_writev", "personality",
"mount", "umount2", "pivot_root", "open_tree", "move_mount", "fsopen", "fsconfig", "fsmount", "mount_setattr",
"unshare", "setns",
"keyctl", "add_key", "request_key",
"bpf", "perf_event_open", "userfaultfd",
"io_uring_setup", "io_uring_enter", "io_uring_register",
"init_module", "finit_module", "delete_module", "kexec_load", "kexec_file_load",
"open_by_handle_at", "name_to_handle_at", "lookup_dcookie",
"swapon", "swapoff", "reboot", "acct", "quotactl", "syslog",
"sethostname", "setdomainname", "settimeofday", "clock_settime", "adjtimex",
"ioperm", "iopl"
];
private static readonly Dictionary<string, uint> X8664 = new()
{
["ptrace"] = 101, ["process_vm_readv"] = 310, ["process_vm_writev"] = 311, ["personality"] = 135,
["mount"] = 165, ["umount2"] = 166, ["pivot_root"] = 155, ["open_tree"] = 428, ["move_mount"] = 429,
["fsopen"] = 430, ["fsconfig"] = 431, ["fsmount"] = 432, ["mount_setattr"] = 442,
["unshare"] = 272, ["setns"] = 308,
["keyctl"] = 250, ["add_key"] = 248, ["request_key"] = 249,
["bpf"] = 321, ["perf_event_open"] = 298, ["userfaultfd"] = 323,
["io_uring_setup"] = 425, ["io_uring_enter"] = 426, ["io_uring_register"] = 427,
["init_module"] = 175, ["finit_module"] = 313, ["delete_module"] = 176, ["kexec_load"] = 246, ["kexec_file_load"] = 320,
["open_by_handle_at"] = 304, ["name_to_handle_at"] = 303, ["lookup_dcookie"] = 212,
["swapon"] = 167, ["swapoff"] = 168, ["reboot"] = 169, ["acct"] = 163, ["quotactl"] = 179, ["syslog"] = 103,
["sethostname"] = 170, ["setdomainname"] = 171, ["settimeofday"] = 164, ["clock_settime"] = 227, ["adjtimex"] = 159,
["ioperm"] = 173, ["iopl"] = 172
};
private static readonly Dictionary<string, uint> Aarch64 = new()
{
["ptrace"] = 117, ["process_vm_readv"] = 270, ["process_vm_writev"] = 271, ["personality"] = 92,
["mount"] = 40, ["umount2"] = 39, ["pivot_root"] = 41, ["open_tree"] = 428, ["move_mount"] = 429,
["fsopen"] = 430, ["fsconfig"] = 431, ["fsmount"] = 432, ["mount_setattr"] = 442,
["unshare"] = 97, ["setns"] = 268,
["keyctl"] = 219, ["add_key"] = 217, ["request_key"] = 218,
["bpf"] = 280, ["perf_event_open"] = 241, ["userfaultfd"] = 282,
["io_uring_setup"] = 425, ["io_uring_enter"] = 426, ["io_uring_register"] = 427,
["init_module"] = 105, ["finit_module"] = 273, ["delete_module"] = 106, ["kexec_load"] = 104, ["kexec_file_load"] = 294,
["open_by_handle_at"] = 265, ["name_to_handle_at"] = 264, ["lookup_dcookie"] = 18,
["swapon"] = 224, ["swapoff"] = 225, ["reboot"] = 142, ["acct"] = 89, ["quotactl"] = 60, ["syslog"] = 116,
["sethostname"] = 161, ["setdomainname"] = 162, ["settimeofday"] = 170, ["clock_settime"] = 112, ["adjtimex"] = 171
// ioperm/iopl do not exist on arm64.
};
/// <summary>Whether a filter exists for this machine's architecture.</summary>
public static bool Supports(Architecture architecture) => architecture is Architecture.X64 or Architecture.Arm64;
/// <summary>The filter as bwrap reads it: an array of <c>struct sock_filter</c>, little-endian.</summary>
public static byte[] Build(Architecture architecture)
{
var (arch, table) = architecture switch
{
Architecture.X64 => (ArchX8664, X8664),
Architecture.Arm64 => (ArchAarch64, Aarch64),
_ => throw new PlatformNotSupportedException($"No seccomp filter for {architecture}.")
};
var numbers = Refused.Where(table.ContainsKey).Select(n => table[n]).Distinct().ToList();
var program = new List<(ushort Code, byte Jt, byte Jf, uint K)>
{
(LdWAbs, 0, 0, 4), // seccomp_data.arch
(JeqK, 1, 0, arch), // our architecture → go on
(RetK, 0, 0, KillProcess), // any other → kill
(LdWAbs, 0, 0, 0) // seccomp_data.nr
};
if (architecture == Architecture.X64)
program.Add((JgeK, (byte)(numbers.Count + 1), 0, X32Bit)); // x32 ABI → EPERM
for (var i = 0; i < numbers.Count; i++)
program.Add((JeqK, (byte)(numbers.Count - i), 0, numbers[i])); // → EPERM, past the rest and ALLOW
program.Add((RetK, 0, 0, Allow));
program.Add((RetK, 0, 0, ErrnoEperm));
var bytes = new byte[program.Count * 8];
for (var i = 0; i < program.Count; i++)
{
var (code, jt, jf, k) = program[i];
BitConverter.TryWriteBytes(bytes.AsSpan(i * 8, 2), code);
bytes[i * 8 + 2] = jt;
bytes[i * 8 + 3] = jf;
BitConverter.TryWriteBytes(bytes.AsSpan(i * 8 + 4, 4), k);
}
return bytes;
}
}
@@ -49,6 +49,9 @@ public sealed record AgentToolContext(
/// <summary>#689: The desk for escalations, conflicts and decisions. <c>null</c> outside the engine.</summary>
public Cases.ICaseDesk? Cases { get; init; }
/// <summary>#654: Where code runs — never in the main process. Unavailable under Windows.</summary>
public Sandbox.ISandboxRunner? Sandbox { get; init; }
/// <summary>#717: Special budgets — set up by an approved request_budget.</summary>
public Budget.ISpecialBudgetRepository? SpecialBudgets { get; init; }
@@ -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";
@@ -0,0 +1,189 @@
using System.Runtime.InteropServices;
using ClawdDotNet.Core.Sandbox;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Sandbox;
/// <summary>
/// #654: What the sandbox is made of — the seccomp program and the bwrap command line. Runs on
/// every platform; whether it actually holds, <see cref="SandboxEscapeTests"/> tries under Linux.
/// </summary>
public sealed class SandboxDefinitionTests
{
// ═══════════════════ seccomp ═══════════════════
private const uint ArchX8664 = 0xC000003E;
private const uint ArchAarch64 = 0xC00000B7;
private const uint Allow = 0x7fff0000;
private const uint Eperm = 0x00050001;
private const uint Kill = 0x80000000;
/// <summary>Runs the filter the way the kernel would, for one syscall.</summary>
private static uint Evaluate(byte[] program, uint arch, uint nr)
{
var pc = 0;
uint accumulator = 0;
for (var steps = 0; steps < 10_000; steps++)
{
var code = BitConverter.ToUInt16(program, pc * 8);
var jt = program[pc * 8 + 2];
var jf = program[pc * 8 + 3];
var k = BitConverter.ToUInt32(program, pc * 8 + 4);
switch (code)
{
case 0x20: accumulator = k == 4 ? arch : k == 0 ? nr : throw new InvalidOperationException($"load {k}"); pc++; break;
case 0x15: pc += 1 + (accumulator == k ? jt : jf); break;
case 0x35: pc += 1 + (accumulator >= k ? jt : jf); break;
case 0x06: return k;
default: throw new InvalidOperationException($"unknown instruction {code:x}");
}
}
throw new InvalidOperationException("no return");
}
[Theory]
[InlineData(101u)] // ptrace
[InlineData(165u)] // mount
[InlineData(272u)] // unshare
[InlineData(321u)] // bpf
[InlineData(425u)] // io_uring_setup
[InlineData(250u)] // keyctl
public void On_x86_64_the_ways_out_are_refused(uint nr)
=> Evaluate(SeccompFilter.Build(Architecture.X64), ArchX8664, nr).ShouldBe(Eperm);
[Theory]
[InlineData(0u)] // read
[InlineData(1u)] // write
[InlineData(57u)] // fork
[InlineData(59u)] // execve
public void Ordinary_work_is_allowed(uint nr)
=> Evaluate(SeccompFilter.Build(Architecture.X64), ArchX8664, nr).ShouldBe(Allow);
[Fact]
public void A_foreign_architecture_is_killed_and_x32_refused()
{
var program = SeccompFilter.Build(Architecture.X64);
Evaluate(program, 0x40000003 /* i386 */, 11).ShouldBe(Kill);
Evaluate(program, ArchX8664, 0x40000000 | 59).ShouldBe(Eperm, "the x32 ABI would slip past 64-bit numbers");
}
[Fact]
public void On_arm64_the_ways_out_are_refused_with_its_own_numbers()
{
var program = SeccompFilter.Build(Architecture.Arm64);
Evaluate(program, ArchAarch64, 117).ShouldBe(Eperm); // ptrace
Evaluate(program, ArchAarch64, 97).ShouldBe(Eperm); // unshare
Evaluate(program, ArchAarch64, 63).ShouldBe(Allow); // read
}
[Fact]
public void Every_refused_call_has_a_number_on_x86_64()
{
var program = SeccompFilter.Build(Architecture.X64);
// 4 head instructions, 1 x32 check, one per refused call, ALLOW and EPERM.
(program.Length / 8).ShouldBe(4 + 1 + SeccompFilter.Refused.Count + 2);
}
// ═══════════════════ the command line ═══════════════════
private static readonly string Workspace = Path.Combine(Path.GetTempPath(), "clawd-tests", "agents", "coder", "Workspace");
private static IReadOnlyList<string> Line(SandboxRequest? request = null)
=> BwrapSandbox.CommandLine("/usr/bin/bwrap", "/tmp/filter.bpf",
request ?? new SandboxRequest { Executable = "ffmpeg", Arguments = ["-i", "/work/in.mp3"], WorkspacePath = Workspace },
HostLayout.MergedUsr, BwrapFeatures.All);
private static bool HasSequence(IReadOnlyList<string> line, params string[] sequence)
=> Enumerable.Range(0, line.Count - sequence.Length + 1).Any(i => sequence.Select((s, j) => line[i + j] == s).All(x => x));
[Fact]
public void Every_namespace_is_its_own_and_no_privilege_is_kept()
{
var line = Line();
line.ShouldContain("--unshare-all");
line.ShouldContain("--unshare-user");
line.ShouldContain("--disable-userns");
line.ShouldContain("--die-with-parent");
line.ShouldContain("--new-session");
HasSequence(line, "--cap-drop", "ALL").ShouldBeTrue();
HasSequence(line, "--seccomp", "3").ShouldBeTrue();
line.ShouldNotContain("--share-net");
}
[Fact]
public void Only_the_workspace_is_writable_and_it_is_at_work()
{
var line = Line();
HasSequence(line, "--bind", Path.GetFullPath(Workspace), "/work").ShouldBeTrue();
HasSequence(line, "--ro-bind", "/usr", "/usr").ShouldBeTrue();
line.Count(a => a == "--bind").ShouldBe(1, "one writable mount, no more");
line.ShouldNotContain(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile));
}
[Fact]
public void Scripts_are_mounted_read_only_after_the_workspace()
{
var line = Line().ToList();
var rw = line.IndexOf("--bind");
var ro = line.FindIndex(a => a == "/work/scripts");
ro.ShouldBeGreaterThan(rw, "bwrap applies mounts in order — the read-only one must come on top");
line[ro - 2].ShouldBe("--ro-bind-try");
}
[Fact]
public void The_environment_is_only_what_the_request_names()
{
var line = Line(new SandboxRequest
{
Executable = "true", WorkspacePath = Workspace,
Environment = new Dictionary<string, string> { ["LANG"] = "de_DE.UTF-8", ["MODE"] = "fast" }
});
var setenv = Enumerable.Range(0, line.Count).Where(i => line[i] == "--setenv").Select(i => line[i + 1]).ToList();
setenv.ShouldBe(["HOME", "LANG", "MODE", "PATH", "TMPDIR"], ignoreOrder: true);
HasSequence(line, "--setenv", "LANG", "de_DE.UTF-8").ShouldBeTrue("the request wins over the defaults");
}
[Fact]
public void The_program_runs_last_behind_the_process_limit()
{
var line = Line(new SandboxRequest
{
Executable = "ffmpeg", Arguments = ["-i", "/work/in.mp3"], WorkspacePath = Workspace,
Limits = new SandboxLimits { MaxProcesses = 12 }
});
line.TakeLast(4).ShouldBe(["12", "ffmpeg", "-i", "/work/in.mp3"]);
line[line.Count - 7].ShouldBe("-c");
}
[Fact]
public void An_older_bwrap_goes_without_what_it_does_not_know()
{
var line = BwrapSandbox.CommandLine("/usr/bin/bwrap", "/tmp/f", new SandboxRequest { Executable = "true", WorkspacePath = Workspace },
HostLayout.MergedUsr, new BwrapFeatures(DisableUserns: false, ClearEnv: false));
line.ShouldNotContain("--disable-userns");
line.ShouldNotContain("--clearenv");
}
[Theory]
[InlineData("in.mp3", "/work/in.mp3")]
[InlineData("YTTranscript/a b.m4a", "/work/YTTranscript/a b.m4a")]
[InlineData("", "/work")]
public void A_path_in_the_workspace_has_its_place_in_the_sandbox(string relative, string expected)
=> SandboxPaths.InSandbox(Workspace, Path.Combine(Workspace, relative)).ShouldBe(expected);
[Fact]
public void A_path_outside_the_workspace_has_none()
{
SandboxPaths.InSandbox(Workspace, Path.Combine(Workspace, "..", "secret.txt")).ShouldBeNull();
SandboxPaths.InSandbox(Workspace, Workspace + "-other/x").ShouldBeNull("a sibling with the same prefix is not inside");
}
}
@@ -0,0 +1,232 @@
using System.Diagnostics;
using ClawdDotNet.Core.Sandbox;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Sandbox;
/// <summary>
/// A test that needs a working bubblewrap sandbox. Elsewhere it is skipped with the reason —
/// a green run on Windows would say nothing about the wall.
/// </summary>
public sealed class SandboxFactAttribute : FactAttribute
{
public SandboxFactAttribute()
{
if (SandboxEscapeTests.Runner.UnavailableReason is { } reason)
Skip = "No sandbox here: " + reason;
}
}
/// <summary>
/// #654: Every guarantee of the sandbox, tried from inside. Each test does what an escaping
/// program would do and expects it to fail.
/// </summary>
public sealed class SandboxEscapeTests : IDisposable
{
internal static readonly ISandboxRunner Runner = BwrapSandbox.Detect(NullLogger.Instance);
private readonly string _root = Path.Combine(Path.GetTempPath(), "clawd-tests", "sandbox-" + Guid.NewGuid().ToString("N")[..8]);
private readonly string _workspace;
private readonly string _instanceSecret;
public SandboxEscapeTests()
{
// An instance as it lies on disk: the agent's workspace next to what must stay out of reach.
_workspace = Path.Combine(_root, "Agents", "coder", "Workspace");
Directory.CreateDirectory(Path.Combine(_workspace, "scripts"));
File.WriteAllText(Path.Combine(_workspace, "scripts", "approved.py"), "print('checked')\n");
File.WriteAllText(Path.Combine(_workspace, "data.txt"), "agent data\n");
_instanceSecret = Path.Combine(_root, "instance.json");
File.WriteAllText(_instanceSecret, "{\"apiKey\":\"sk-secret\"}");
Directory.CreateDirectory(Path.Combine(_root, "Agents", "other", "Workspace"));
File.WriteAllText(Path.Combine(_root, "Agents", "other", "Workspace", "theirs.txt"), "not yours");
}
public void Dispose()
{
try { Directory.Delete(_root, recursive: true); }
catch { /* cleanup is secondary */ }
}
private Task<SandboxResult> Sh(string script, SandboxLimits? limits = null, IReadOnlyDictionary<string, string>? environment = null)
=> Runner.RunAsync(new SandboxRequest
{
Executable = "/bin/sh",
Arguments = ["-c", script],
WorkspacePath = _workspace,
Limits = limits ?? new SandboxLimits { Timeout = TimeSpan.FromSeconds(20) },
Environment = environment ?? new Dictionary<string, string>()
}, CancellationToken.None);
// ═══════════════════ file system ═══════════════════
[SandboxFact]
public async Task The_workspace_is_there_and_writable()
{
var result = await Sh("cat /work/data.txt && echo written > /work/out.txt");
result.ExitCode.ShouldBe(0, result.StandardError);
result.StandardOutput.ShouldContain("agent data");
File.ReadAllText(Path.Combine(_workspace, "out.txt")).ShouldContain("written");
}
[SandboxFact]
public async Task The_instance_other_workspaces_and_home_do_not_exist()
{
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var result = await Sh(
$"for p in '{_instanceSecret}' '{_root}/Agents/other/Workspace/theirs.txt' '{home}' '{_root}'; do " +
"if [ -e \"$p\" ]; then echo \"VISIBLE $p\"; fi; done; echo done");
result.StandardOutput.ShouldNotContain("VISIBLE");
result.StandardOutput.ShouldContain("done");
}
[SandboxFact]
public async Task The_system_cannot_be_written()
{
var result = await Sh("touch /usr/bin/planted 2>/dev/null && echo PLANTED; touch /etc/planted 2>/dev/null && echo PLANTED; echo done");
result.StandardOutput.ShouldNotContain("PLANTED");
}
[SandboxFact]
public async Task What_was_checked_cannot_be_changed_from_inside()
{
var result = await Sh("echo 'import os; os.system(\"curl evil\")' >> /work/scripts/approved.py 2>/dev/null && echo CHANGED; " +
"rm -f /work/scripts/approved.py 2>/dev/null; echo done");
result.StandardOutput.ShouldNotContain("CHANGED");
File.ReadAllText(Path.Combine(_workspace, "scripts", "approved.py")).ShouldBe("print('checked')\n");
}
// ═══════════════════ inheritance ═══════════════════
[SandboxFact]
public async Task Nothing_of_the_main_process_environment_arrives()
{
Environment.SetEnvironmentVariable("CLAWD_TEST_SECRET", "sk-should-not-leak");
try
{
var result = await Sh("env", environment: new Dictionary<string, string> { ["GIVEN"] = "yes" });
result.StandardOutput.ShouldNotContain("sk-should-not-leak");
result.StandardOutput.ShouldContain("GIVEN=yes");
}
finally
{
Environment.SetEnvironmentVariable("CLAWD_TEST_SECRET", null);
}
}
[SandboxFact]
public async Task Only_the_standard_descriptors_are_open()
{
using var held = File.OpenRead(_instanceSecret); // a file the main process has open
// Listed by a child: a shell globbing its own /proc/$$/fd opens that directory and
// would count the handle as fd 3.
var result = await Sh("ls /proc/$$/fd");
result.StandardOutput.Split('\n', StringSplitOptions.RemoveEmptyEntries).ShouldBe(["0", "1", "2"], ignoreOrder: true);
}
// ═══════════════════ network and privileges ═══════════════════
[SandboxFact]
public async Task There_is_no_network_but_loopback()
{
var result = await Sh("tail -n +3 /proc/net/dev | cut -d: -f1 | tr -d ' '");
result.StandardOutput.Trim().ShouldBe("lo");
}
[SandboxFact]
public async Task No_privileges_and_the_filter_is_in_place()
{
var result = await Sh("grep -E '^(CapEff|NoNewPrivs|Seccomp):' /proc/self/status");
result.StandardOutput.ShouldContain("CapEff:\t0000000000000000");
result.StandardOutput.ShouldContain("NoNewPrivs:\t1");
result.StandardOutput.ShouldMatch(@"Seccomp:\s+2");
}
[SandboxFact]
public async Task New_namespaces_and_mounts_are_refused()
{
var result = await Sh("unshare --user true 2>/dev/null && echo UNSHARED; mount -t tmpfs none /tmp 2>/dev/null && echo MOUNTED; echo done");
result.StandardOutput.ShouldNotContain("UNSHARED");
result.StandardOutput.ShouldNotContain("MOUNTED");
result.StandardOutput.ShouldContain("done");
}
// ═══════════════════ limits ═══════════════════
[SandboxFact]
public async Task A_run_that_does_not_end_is_ended()
{
var watch = Stopwatch.StartNew();
var result = await Sh("sleep 60", new SandboxLimits { Timeout = TimeSpan.FromSeconds(1) });
result.TimedOut.ShouldBeTrue();
watch.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(15));
}
[SandboxFact]
public async Task Endless_output_is_cut_off_and_the_run_stopped()
{
var result = await Sh("yes", new SandboxLimits { Timeout = TimeSpan.FromSeconds(20), MaxOutputBytes = 10_000 });
result.OutputTruncated.ShouldBeTrue();
result.StandardOutput.Length.ShouldBeLessThanOrEqualTo(10_000);
result.TimedOut.ShouldBeFalse("stopped for its output, not left to run into the timeout");
}
[SandboxFact]
public async Task A_fork_bomb_hits_the_process_limit()
{
var result = await Sh("i=0; while [ $i -lt 40 ]; do sleep 5 & i=$((i+1)); done; echo started $i",
new SandboxLimits { Timeout = TimeSpan.FromSeconds(20), MaxProcesses = 10 });
(result.StandardError + result.StandardOutput).ShouldMatch("(?i)(fork|resource temporarily unavailable|cannot)");
}
[SandboxFact]
public async Task The_limit_counts_the_sandbox_not_the_instances_threads()
{
// The instance itself runs hundreds of threads under the same user. Were those counted,
// the first fork would fail; five of ten must go.
var result = await Sh("i=0; while [ $i -lt 5 ]; do sleep 1 & i=$((i+1)); done; wait; echo started $i",
new SandboxLimits { Timeout = TimeSpan.FromSeconds(20), MaxProcesses = 10 });
result.StandardOutput.ShouldContain("started 5", customMessage: result.StandardError);
}
[SandboxFact]
public async Task The_programs_exit_code_comes_back()
=> (await Sh("exit 3")).ExitCode.ShouldBe(3);
[SandboxFact]
public async Task Input_reaches_the_program()
{
var result = await Runner.RunAsync(new SandboxRequest
{
Executable = "/bin/cat", WorkspacePath = _workspace, StandardInput = "hello sandbox"
}, CancellationToken.None);
result.StandardOutput.ShouldBe("hello sandbox");
}
// ═══════════════════ refused requests ═══════════════════
[SandboxFact]
public async Task A_workspace_that_is_home_is_refused()
=> await Should.ThrowAsync<ArgumentException>(() => Runner.RunAsync(new SandboxRequest
{
Executable = "/bin/true", WorkspacePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)
}, CancellationToken.None));
}