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_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)); }