Sandbox-Runner mit bubblewrap (#654) - noch ungeprueft unter Linux
Jede Codeausfuehrung ausserhalb des Hauptprozesses: eigene Namensraeume (Mount, PID, Netz nur loopback, User ohne verschachtelte), nur der Arbeitsbereich unter /work beschreibbar, scripts/ darin schreibgeschuetzt, keine Umgebung und keine Deskriptoren des Hauptprozesses, alle Capabilities weg, no_new_privs, seccomp-Sperrliste (eigene BPF, x86_64 und arm64), Grenzen fuer Zeit, Speicher, Prozesse, Dateigroesse und Ausgabe. Unter Windows oder ohne bwrap: keine Ausfuehrung, mit Grund im Protokoll. Erster Nutzer: ffmpeg im SocialMediaManager (fremde Mediendateien). yt-dlp braucht Netz und folgt mit dem Egress-Proxy (#655). Die Ausbruchstests (SandboxEscapeTests) laufen nur unter Linux mit bwrap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8c5eca18f5
commit
257ba87cf5
@@ -589,6 +589,15 @@ public sealed class AppHost : IAsyncDisposable
|
|||||||
SpecialBudgets = specialBudgets
|
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(
|
engine.SetAgentConfigProvider(
|
||||||
() => instance.Agents,
|
() => instance.Agents,
|
||||||
instance.InstanceId,
|
instance.InstanceId,
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public Budget.ISpecialBudgetRepository? SpecialBudgets { get; set; }
|
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()
|
private Organisation.OrgChart CurrentOrganisation()
|
||||||
=> OrganisationProvider?.Invoke() ?? new Organisation.OrgChart([], _agentConfigProvider?.Invoke() ?? []);
|
=> OrganisationProvider?.Invoke() ?? new Organisation.OrgChart([], _agentConfigProvider?.Invoke() ?? []);
|
||||||
|
|
||||||
@@ -1764,6 +1767,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
|||||||
Trust = ReportingLine.TrustOf(agentConfig, _toolRegistry.Get),
|
Trust = ReportingLine.TrustOf(agentConfig, _toolRegistry.Get),
|
||||||
Cases = CaseDesk,
|
Cases = CaseDesk,
|
||||||
SpecialBudgets = SpecialBudgets,
|
SpecialBudgets = SpecialBudgets,
|
||||||
|
Sandbox = Sandbox,
|
||||||
// #697: Where something an agent hands on came from — known to the engine, not claimed by the agent.
|
// #697: Where something an agent hands on came from — known to the engine, not claimed by the agent.
|
||||||
RunId = runId,
|
RunId = runId,
|
||||||
RunTaskId = runId is null ? null : Activity.Find(runId)?.TaskId
|
RunTaskId = runId is null ? null : Activity.Find(runId)?.TaskId
|
||||||
|
|||||||
@@ -0,0 +1,371 @@
|
|||||||
|
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"]);
|
||||||
|
var probe = RunQuiet(bwrap, ["--unshare-all", "--die-with-parent", "--ro-bind", "/usr", "/usr",
|
||||||
|
"--symlink", "usr/bin", "/bin", "--symlink", "usr/lib", "/lib", "--proc", "/proc", "--dev", "/dev", "/bin/true"]);
|
||||||
|
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)]);
|
||||||
|
|
||||||
|
args.AddRange(["--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: the process limit, set inside the sandbox's own user namespace.
|
||||||
|
args.AddRange([Shell, "-c", """ulimit -u "$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 = Process.Start(start) ?? throw new InvalidOperationException("The sandbox did not 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.WhenAll(stdout, stderr);
|
||||||
|
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>bwrap dies, and with it — die-with-parent, own PID namespace — everything inside.</summary>
|
||||||
|
private static void Kill(Process process)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!process.HasExited)
|
||||||
|
process.Kill(entireProcessTree: true);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException) { }
|
||||||
|
catch (System.ComponentModel.Win32Exception) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 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>
|
/// <summary>#689: The desk for escalations, conflicts and decisions. <c>null</c> outside the engine.</summary>
|
||||||
public Cases.ICaseDesk? Cases { get; init; }
|
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>
|
/// <summary>#717: Special budgets — set up by an approved request_budget.</summary>
|
||||||
public Budget.ISpecialBudgetRepository? SpecialBudgets { get; init; }
|
public Budget.ISpecialBudgetRepository? SpecialBudgets { get; init; }
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ using Microsoft.Extensions.Logging;
|
|||||||
|
|
||||||
namespace ClawdDotNet.Tools.SocialMediaManager;
|
namespace ClawdDotNet.Tools.SocialMediaManager;
|
||||||
|
|
||||||
public sealed class SocialMediaManagerTool : IAgentTool, IToolJobProvider
|
public sealed class SocialMediaManagerTool : IAgentTool, IToolJobProvider, ClawdDotNet.Core.Sandbox.ISandboxUser
|
||||||
{
|
{
|
||||||
public string Name => "SocialMediaManager";
|
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}",
|
logger.LogInformation("Starting transcription for {File} ({Size:F1} MB) via OpenRouter ({Model}), ffmpeg={HasFfmpeg}",
|
||||||
Path.GetFileName(audioFile), fileSize / 1024.0 / 1024.0, model, 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
|
// 4. Save transcript
|
||||||
var transcriptFile = Path.Combine(targetDir, $"{videoId}.txt");
|
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)
|
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,
|
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);
|
var fileInfo = new FileInfo(filePath);
|
||||||
if (!fileInfo.Exists)
|
if (!fileInfo.Exists)
|
||||||
@@ -596,8 +601,45 @@ public sealed class SocialMediaManagerTool : IAgentTool, IToolJobProvider
|
|||||||
{
|
{
|
||||||
List<string> chunks;
|
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)
|
// ffmpeg: 20-minute segments as mp3 (under 25 MB Whisper limit at q:a 5)
|
||||||
var chunkPattern = Path.Combine(chunkDir, "chunk_%03d.mp3");
|
var chunkPattern = Path.Combine(chunkDir, "chunk_%03d.mp3");
|
||||||
var ffmpegExe = !string.IsNullOrEmpty(ffmpegPath) ? ffmpegPath : "ffmpeg";
|
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,219 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
var result = await Sh("for f in /proc/$$/fd/*; do echo ${f##*/}; done");
|
||||||
|
|
||||||
|
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_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));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user