using System.Runtime.InteropServices;
using ClawdDotNet.Core.Sandbox;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Sandbox;
///
/// #654: What the sandbox is made of — the seccomp program and the bwrap command line. Runs on
/// every platform; whether it actually holds, tries under Linux.
///
public sealed class SandboxDefinitionTests
{
// ═══════════════════ seccomp ═══════════════════
private const uint ArchX8664 = 0xC000003E;
private const uint ArchAarch64 = 0xC00000B7;
private const uint Allow = 0x7fff0000;
private const uint Eperm = 0x00050001;
private const uint Kill = 0x80000000;
/// Runs the filter the way the kernel would, for one syscall.
private static uint Evaluate(byte[] program, uint arch, uint nr)
{
var pc = 0;
uint accumulator = 0;
for (var steps = 0; steps < 10_000; steps++)
{
var code = BitConverter.ToUInt16(program, pc * 8);
var jt = program[pc * 8 + 2];
var jf = program[pc * 8 + 3];
var k = BitConverter.ToUInt32(program, pc * 8 + 4);
switch (code)
{
case 0x20: accumulator = k == 4 ? arch : k == 0 ? nr : throw new InvalidOperationException($"load {k}"); pc++; break;
case 0x15: pc += 1 + (accumulator == k ? jt : jf); break;
case 0x35: pc += 1 + (accumulator >= k ? jt : jf); break;
case 0x06: return k;
default: throw new InvalidOperationException($"unknown instruction {code:x}");
}
}
throw new InvalidOperationException("no return");
}
[Theory]
[InlineData(101u)] // ptrace
[InlineData(165u)] // mount
[InlineData(272u)] // unshare
[InlineData(321u)] // bpf
[InlineData(425u)] // io_uring_setup
[InlineData(250u)] // keyctl
public void On_x86_64_the_ways_out_are_refused(uint nr)
=> Evaluate(SeccompFilter.Build(Architecture.X64), ArchX8664, nr).ShouldBe(Eperm);
[Theory]
[InlineData(0u)] // read
[InlineData(1u)] // write
[InlineData(57u)] // fork
[InlineData(59u)] // execve
public void Ordinary_work_is_allowed(uint nr)
=> Evaluate(SeccompFilter.Build(Architecture.X64), ArchX8664, nr).ShouldBe(Allow);
[Fact]
public void A_foreign_architecture_is_killed_and_x32_refused()
{
var program = SeccompFilter.Build(Architecture.X64);
Evaluate(program, 0x40000003 /* i386 */, 11).ShouldBe(Kill);
Evaluate(program, ArchX8664, 0x40000000 | 59).ShouldBe(Eperm, "the x32 ABI would slip past 64-bit numbers");
}
[Fact]
public void On_arm64_the_ways_out_are_refused_with_its_own_numbers()
{
var program = SeccompFilter.Build(Architecture.Arm64);
Evaluate(program, ArchAarch64, 117).ShouldBe(Eperm); // ptrace
Evaluate(program, ArchAarch64, 97).ShouldBe(Eperm); // unshare
Evaluate(program, ArchAarch64, 63).ShouldBe(Allow); // read
}
[Fact]
public void Every_refused_call_has_a_number_on_x86_64()
{
var program = SeccompFilter.Build(Architecture.X64);
// 4 head instructions, 1 x32 check, one per refused call, ALLOW and EPERM.
(program.Length / 8).ShouldBe(4 + 1 + SeccompFilter.Refused.Count + 2);
}
// ═══════════════════ the command line ═══════════════════
private static readonly string Workspace = Path.Combine(Path.GetTempPath(), "clawd-tests", "agents", "coder", "Workspace");
private static IReadOnlyList Line(SandboxRequest? request = null)
=> BwrapSandbox.CommandLine("/usr/bin/bwrap", "/tmp/filter.bpf",
request ?? new SandboxRequest { Executable = "ffmpeg", Arguments = ["-i", "/work/in.mp3"], WorkspacePath = Workspace },
HostLayout.MergedUsr, BwrapFeatures.All);
private static bool HasSequence(IReadOnlyList line, params string[] sequence)
=> Enumerable.Range(0, line.Count - sequence.Length + 1).Any(i => sequence.Select((s, j) => line[i + j] == s).All(x => x));
[Fact]
public void Every_namespace_is_its_own_and_no_privilege_is_kept()
{
var line = Line();
line.ShouldContain("--unshare-all");
line.ShouldContain("--unshare-user");
line.ShouldContain("--disable-userns");
line.ShouldContain("--die-with-parent");
line.ShouldContain("--new-session");
HasSequence(line, "--cap-drop", "ALL").ShouldBeTrue();
HasSequence(line, "--seccomp", "3").ShouldBeTrue();
line.ShouldNotContain("--share-net");
}
[Fact]
public void Only_the_workspace_is_writable_and_it_is_at_work()
{
var line = Line();
HasSequence(line, "--bind", Path.GetFullPath(Workspace), "/work").ShouldBeTrue();
HasSequence(line, "--ro-bind", "/usr", "/usr").ShouldBeTrue();
line.Count(a => a == "--bind").ShouldBe(1, "one writable mount, no more");
line.ShouldNotContain(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile));
}
[Fact]
public void Scripts_are_mounted_read_only_after_the_workspace()
{
var line = Line().ToList();
var rw = line.IndexOf("--bind");
var ro = line.FindIndex(a => a == "/work/scripts");
ro.ShouldBeGreaterThan(rw, "bwrap applies mounts in order — the read-only one must come on top");
line[ro - 2].ShouldBe("--ro-bind-try");
}
[Fact]
public void The_environment_is_only_what_the_request_names()
{
var line = Line(new SandboxRequest
{
Executable = "true", WorkspacePath = Workspace,
Environment = new Dictionary { ["LANG"] = "de_DE.UTF-8", ["MODE"] = "fast" }
});
var setenv = Enumerable.Range(0, line.Count).Where(i => line[i] == "--setenv").Select(i => line[i + 1]).ToList();
setenv.ShouldBe(["HOME", "LANG", "MODE", "PATH", "TMPDIR"], ignoreOrder: true);
HasSequence(line, "--setenv", "LANG", "de_DE.UTF-8").ShouldBeTrue("the request wins over the defaults");
}
[Fact]
public void The_program_runs_last_behind_the_process_limit()
{
var line = Line(new SandboxRequest
{
Executable = "ffmpeg", Arguments = ["-i", "/work/in.mp3"], WorkspacePath = Workspace,
Limits = new SandboxLimits { MaxProcesses = 12 }
});
line.TakeLast(4).ShouldBe(["12", "ffmpeg", "-i", "/work/in.mp3"]);
line[line.Count - 7].ShouldBe("-c");
}
[Fact]
public void An_older_bwrap_goes_without_what_it_does_not_know()
{
var line = BwrapSandbox.CommandLine("/usr/bin/bwrap", "/tmp/f", new SandboxRequest { Executable = "true", WorkspacePath = Workspace },
HostLayout.MergedUsr, new BwrapFeatures(DisableUserns: false, ClearEnv: false));
line.ShouldNotContain("--disable-userns");
line.ShouldNotContain("--clearenv");
}
[Theory]
[InlineData("in.mp3", "/work/in.mp3")]
[InlineData("YTTranscript/a b.m4a", "/work/YTTranscript/a b.m4a")]
[InlineData("", "/work")]
public void A_path_in_the_workspace_has_its_place_in_the_sandbox(string relative, string expected)
=> SandboxPaths.InSandbox(Workspace, Path.Combine(Workspace, relative)).ShouldBe(expected);
[Fact]
public void A_path_outside_the_workspace_has_none()
{
SandboxPaths.InSandbox(Workspace, Path.Combine(Workspace, "..", "secret.txt")).ShouldBeNull();
SandboxPaths.InSandbox(Workspace, Workspace + "-other/x").ShouldBeNull("a sibling with the same prefix is not inside");
}
}