diff --git a/docs/Security-Konzept.md b/docs/Security-Konzept.md index 6b93239..307b6f0 100644 --- a/docs/Security-Konzept.md +++ b/docs/Security-Konzept.md @@ -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`, diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs index b6a83c9..91b77a1 100644 --- a/src/ClawdDotNet.App/AppHost.cs +++ b/src/ClawdDotNet.App/AppHost.cs @@ -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()) + user.Sandbox = sandbox; + engine.SetAgentConfigProvider( () => instance.Agents, instance.InstanceId, diff --git a/src/ClawdDotNet.Core/Engine/AgentEngine.cs b/src/ClawdDotNet.Core/Engine/AgentEngine.cs index a2baea5..877dc3a 100644 --- a/src/ClawdDotNet.Core/Engine/AgentEngine.cs +++ b/src/ClawdDotNet.Core/Engine/AgentEngine.cs @@ -60,6 +60,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut /// public Budget.ISpecialBudgetRepository? SpecialBudgets { get; set; } + /// #654: The sandbox tools run code in. + 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 diff --git a/src/ClawdDotNet.Core/Sandbox/BwrapSandbox.cs b/src/ClawdDotNet.Core/Sandbox/BwrapSandbox.cs new file mode 100644 index 0000000..29b1043 --- /dev/null +++ b/src/ClawdDotNet.Core/Sandbox/BwrapSandbox.cs @@ -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; + +/// +/// #654: The sandbox under Linux, built on bubblewrap. +/// +/// What holds, and where it comes from (each with a test that tries to break it): +/// +/// File system: own mount namespace. /usr and the few files of +/// /etc a program needs are mounted read-only; the workspace at /work is the +/// only writable place besides a private /tmp. $HOME, the instance, the key +/// ring and other workspaces are not mounted at all. +/// Inheritance: 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. +/// Network: own network namespace with nothing but loopback. +/// Limits: 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. +/// Privileges: all capabilities dropped, no_new_privs (bwrap sets it +/// always), no nested user namespaces where bwrap can refuse them, and the +/// . +/// +/// +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; + + /// + /// 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 with the reason. + /// + 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 { "--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 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 */ } + } + } + + /// + /// The whole command line: /bin/sh -c WRAPPER sh FILTER LIMITS… bwrap ARGS… -- sh -c INNER sh NPROC PROGRAM ARGS…. + /// Pure — the tests read it on any platform. + /// + public static IReadOnlyList CommandLine( + string bwrap, string filterFile, SandboxRequest request, HostLayout host, BwrapFeatures features) + { + var limits = request.Limits; + var args = new List + { + "-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(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; + } + + /// What a program commonly needs of /etc — and nothing that says who uses this machine. + 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" + ]; + + /// The workspace must exist and must not be a place that contains everything else. + 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 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 + }; + } + + /// Reads one stream into the shared cap; past the cap the run is stopped. + 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; + } + } + } + + /// + /// Stops the run: everything below bwrap is collected first, then all of it is killed. + /// + /// Not trusting --die-with-parent. 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. + /// + 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) { } + } + } + + /// All processes below , from /proc — collected before anything is killed. + private static IEnumerable Descendants(int root) + { + var children = new Dictionary>(); + foreach (var dir in Directory.EnumerateDirectories("/proc")) + { + if (!int.TryParse(Path.GetFileName(dir), out var pid)) + continue; + try + { + // /proc//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(); + var queue = new Queue([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); + + /// ulimit -f counts 512-byte blocks in POSIX sh. + private static string Blocks(long bytes) => Math.Max(1, bytes / 512).ToString(CultureInfo.InvariantCulture); + + private static (int ExitCode, string Output) RunQuiet(string program, IReadOnlyList 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 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; + } + } + } +} + +/// +/// #654: Starts every sandbox from one thread that lives as long as the process. +/// +/// Why. --die-with-parent uses the kernel's parent-death signal, and that +/// is bound to the thread 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. +/// +internal static class Spawner +{ + private static readonly System.Collections.Concurrent.BlockingCollection<(ProcessStartInfo Start, TaskCompletionSource 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 StartAsync(ProcessStartInfo start) + { + var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Queue.Add((start, done)); + return done.Task; + } +} + +/// #654: What the installed bwrap can do beyond the basics. +public sealed record BwrapFeatures(bool DisableUserns, bool ClearEnv) +{ + public static BwrapFeatures All { get; } = new(true, true); +} + +/// +/// #654: How the top level of this machine looks — which of /bin, /lib … are +/// links into /usr (merged /usr) and which are directories. Read once per run; a +/// record, so the tests can describe any machine. +/// +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); + } + + /// A merged-/usr system, as Debian and Ubuntu are today. + public static HostLayout MergedUsr { get; } = new([("/bin", "usr/bin"), ("/sbin", "usr/sbin"), ("/lib", "usr/lib"), ("/lib64", "usr/lib64")]); +} diff --git a/src/ClawdDotNet.Core/Sandbox/SandboxModels.cs b/src/ClawdDotNet.Core/Sandbox/SandboxModels.cs new file mode 100644 index 0000000..641c340 --- /dev/null +++ b/src/ClawdDotNet.Core/Sandbox/SandboxModels.cs @@ -0,0 +1,129 @@ +namespace ClawdDotNet.Core.Sandbox; + +/// +/// #654: What may run in the sandbox, and what it may see. +/// +/// Only the workspace is there. It is mounted at +/// and is the only writable place besides a private /tmp. The system is read-only; +/// $HOME, the instance directory, the key ring and other agents' workspaces do not +/// exist in the sandbox — not blocked, absent. +/// +public sealed record SandboxRequest +{ + /// The program, as the sandbox sees it: ffmpeg (from the system's PATH) or an absolute path. + public required string Executable { get; init; } + + public IReadOnlyList Arguments { get; init; } = []; + + /// The agent's workspace on the host — mounted writable at /work. + public required string WorkspacePath { get; init; } + + /// + /// 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). + /// + public IReadOnlyList ProtectedPaths { get; init; } = SandboxPaths.DefaultProtected; + + /// Further host paths made visible read-only, at the same path — e.g. a program outside /usr. + public IReadOnlyList ReadOnlyPaths { get; init; } = []; + + /// The environment — only this. Nothing of the main process's is inherited. + public IReadOnlyDictionary Environment { get; init; } = new Dictionary(); + + public SandboxLimits Limits { get; init; } = new(); + + public string? StandardInput { get; init; } +} + +/// #654: The limits of one run. +public sealed record SandboxLimits +{ + public TimeSpan Timeout { get; init; } = TimeSpan.FromMinutes(5); + + /// Address space per process. + public long MaxMemoryBytes { get; init; } = 2L * 1024 * 1024 * 1024; + + /// Processes and threads the run may have at once — a fork bomb stops here. + public int MaxProcesses { get; init; } = 64; + + /// The largest file the run may write. + public long MaxFileBytes { get; init; } = 1024L * 1024 * 1024; + + /// Stdout and stderr together; beyond it the run is stopped. + 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; +} + +/// +/// #654: Runs code outside the main process. The one way code runs — shell (#196), approved +/// scripts, MCP stdio servers (#197), the media tools. +/// +public interface ISandboxRunner +{ + /// Whether code can run at all. Without a sandbox it does not — the function is missing, not weaker. + bool IsAvailable { get; } + + /// Why not, if is false. + string? UnavailableReason { get; } + + Task RunAsync(SandboxRequest request, CancellationToken ct); +} + +/// +/// #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 AgentToolContext.Sandbox is not there. +/// +public interface ISandboxUser +{ + ISandboxRunner? Sandbox { get; set; } +} + +/// #654: Where there is no sandbox (Windows, or no bwrap): nothing runs. +public sealed class NoSandbox(string reason) : ISandboxRunner +{ + public bool IsAvailable => false; + public string? UnavailableReason => reason; + + public Task RunAsync(SandboxRequest request, CancellationToken ct) + => throw new SandboxUnavailableException(reason); +} + +public sealed class SandboxUnavailableException(string reason) + : InvalidOperationException($"Code execution is not available: {reason}"); + +/// #654: Paths as the sandbox sees them. +public static class SandboxPaths +{ + public const string Work = "/work"; + + /// + /// In the workspace, read-only by default: scripts/ holds approved scripts and their + /// manifests (#695) — what is checked must stay what runs. + /// + public static readonly IReadOnlyList DefaultProtected = ["scripts"]; + + /// A host path inside the workspace, as the sandbox sees it; null if it lies outside. + 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('\\', '/'); + } +} diff --git a/src/ClawdDotNet.Core/Sandbox/SeccompFilter.cs b/src/ClawdDotNet.Core/Sandbox/SeccompFilter.cs new file mode 100644 index 0000000..830e42a --- /dev/null +++ b/src/ClawdDotNet.Core/Sandbox/SeccompFilter.cs @@ -0,0 +1,126 @@ +using System.Runtime.InteropServices; + +namespace ClawdDotNet.Core.Sandbox; + +/// +/// #654: The seccomp filter of the sandbox — a classic BPF program that bwrap installs +/// before the program starts (--seccomp). +/// +/// A deny list, deliberately. 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 EPERM, not killed: a program that probes for a +/// feature then falls back instead of dying mysteriously. +/// +/// 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. +/// +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; + + /// The refused calls, by name — the numbers differ by architecture. + public static readonly IReadOnlyList 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 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 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. + }; + + /// Whether a filter exists for this machine's architecture. + public static bool Supports(Architecture architecture) => architecture is Architecture.X64 or Architecture.Arm64; + + /// The filter as bwrap reads it: an array of struct sock_filter, little-endian. + 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; + } +} diff --git a/src/ClawdDotNet.Core/Tools/AgentToolContext.cs b/src/ClawdDotNet.Core/Tools/AgentToolContext.cs index 9afdf05..74933fd 100644 --- a/src/ClawdDotNet.Core/Tools/AgentToolContext.cs +++ b/src/ClawdDotNet.Core/Tools/AgentToolContext.cs @@ -49,6 +49,9 @@ public sealed record AgentToolContext( /// #689: The desk for escalations, conflicts and decisions. null outside the engine. public Cases.ICaseDesk? Cases { get; init; } + /// #654: Where code runs — never in the main process. Unavailable under Windows. + public Sandbox.ISandboxRunner? Sandbox { get; init; } + /// #717: Special budgets — set up by an approved request_budget. public Budget.ISpecialBudgetRepository? SpecialBudgets { get; init; } diff --git a/src/ClawdDotNet.Tools.SocialMediaManager/SocialMediaManagerTool.cs b/src/ClawdDotNet.Tools.SocialMediaManager/SocialMediaManagerTool.cs index 318c602..b2ddf90 100644 --- a/src/ClawdDotNet.Tools.SocialMediaManager/SocialMediaManagerTool.cs +++ b/src/ClawdDotNet.Tools.SocialMediaManager/SocialMediaManagerTool.cs @@ -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) + /// #654: Where ffmpeg runs — set by the instance at start. + public ClawdDotNet.Core.Sandbox.ISandboxRunner? Sandbox { get; set; } + + private int _warnedUnsandboxed; + private async Task 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 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"; diff --git a/tests/ClawdDotNet.Core.Tests/Sandbox/SandboxDefinitionTests.cs b/tests/ClawdDotNet.Core.Tests/Sandbox/SandboxDefinitionTests.cs new file mode 100644 index 0000000..8713507 --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Sandbox/SandboxDefinitionTests.cs @@ -0,0 +1,189 @@ +using System.Runtime.InteropServices; +using ClawdDotNet.Core.Sandbox; +using Shouldly; + +namespace ClawdDotNet.Core.Tests.Sandbox; + +/// +/// #654: What the sandbox is made of — the seccomp program and the bwrap command line. Runs on +/// every platform; whether it actually holds, tries under Linux. +/// +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; + + /// Runs the filter the way the kernel would, for one syscall. + 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 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 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 { ["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"); + } +} diff --git a/tests/ClawdDotNet.Core.Tests/Sandbox/SandboxEscapeTests.cs b/tests/ClawdDotNet.Core.Tests/Sandbox/SandboxEscapeTests.cs new file mode 100644 index 0000000..2307368 --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Sandbox/SandboxEscapeTests.cs @@ -0,0 +1,232 @@ +using System.Diagnostics; +using ClawdDotNet.Core.Sandbox; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; + +namespace ClawdDotNet.Core.Tests.Sandbox; + +/// +/// 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. +/// +public sealed class SandboxFactAttribute : FactAttribute +{ + public SandboxFactAttribute() + { + if (SandboxEscapeTests.Runner.UnavailableReason is { } reason) + Skip = "No sandbox here: " + reason; + } +} + +/// +/// #654: Every guarantee of the sandbox, tried from inside. Each test does what an escaping +/// program would do and expects it to fail. +/// +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 Sh(string script, SandboxLimits? limits = null, IReadOnlyDictionary? 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() + }, 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 { ["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(() => Runner.RunAsync(new SandboxRequest + { + Executable = "/bin/true", WorkspacePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + }, CancellationToken.None)); +}