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
@@ -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