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"); } // ═══════════════════ #712: launcher, network, secrets, mounts ═══════════════════ private static readonly SandboxHelper Helper = new( "/opt/clawd/ClawdDotNet", ["/opt/clawd"], new Dictionary { ["DOTNET_ROOT"] = "/usr/lib/dotnet" }); private static IReadOnlyList LaunchLine(SandboxRequest request) => BwrapSandbox.CommandLine("/usr/bin/bwrap", "/tmp/f", request, HostLayout.MergedUsr, BwrapFeatures.All, Helper, new LaunchFiles("/tmp/p/egress.sock", "/tmp/p/env.json")); private static SandboxRequest ForeignRequest() => new() { Executable = "/opt/claude/claude", Arguments = ["-p"], WorkspacePath = Workspace, Egress = ["api.anthropic.com"], SecretEnvironment = new Dictionary { ["ANTHROPIC_API_KEY"] = "sk-ant-secret" }, Mounts = [new SandboxMount("/srv/agent/home", "/home/agent", Writable: true), new SandboxMount("/srv/run/private", "/run/agent")] }; [Fact] public void A_run_with_network_or_secrets_starts_behind_the_launcher() { var line = LaunchLine(ForeignRequest()); line.TakeLast(9).ShouldBe(["/opt/clawd/ClawdDotNet", "--sandbox-launch", "--egress", SandboxPaths.EgressSocket, "--secrets", SandboxPaths.SecretFile, "--", "/opt/claude/claude", "-p"]); HasSequence(line, "--bind", "/tmp/p/egress.sock", SandboxPaths.EgressSocket).ShouldBeTrue(); HasSequence(line, "--ro-bind", "/tmp/p/env.json", SandboxPaths.SecretFile).ShouldBeTrue(); HasSequence(line, "--ro-bind", "/opt/clawd", "/opt/clawd").ShouldBeTrue(); HasSequence(line, "--setenv", "HTTPS_PROXY", $"http://127.0.0.1:{SandboxPaths.ProxyPort}").ShouldBeTrue(); } [Fact] public void The_sandbox_keeps_its_own_network_even_with_egress() { var line = LaunchLine(ForeignRequest()); line.ShouldContain("--unshare-all"); line.ShouldNotContain("--share-net"); } [Fact] public void A_secret_is_never_on_the_command_line() => LaunchLine(ForeignRequest()).ShouldNotContain(a => a.Contains("sk-ant-secret")); [Fact] public void Mounts_come_at_their_own_places_and_only_there() { var line = LaunchLine(ForeignRequest()); HasSequence(line, "--bind", Path.GetFullPath("/srv/agent/home"), "/home/agent").ShouldBeTrue(); HasSequence(line, "--ro-bind", Path.GetFullPath("/srv/run/private"), "/run/agent").ShouldBeTrue(); foreach (var target in new[] { "/work/.claude", "/work", "relative", "/home/../etc" }) Should.Throw(() => LaunchLine(ForeignRequest() with { Mounts = [new SandboxMount("/srv/x", target)] }), target); } [Fact] public void Without_the_launcher_such_a_run_is_refused_not_run_bare() => Should.Throw(() => BwrapSandbox.CommandLine("/usr/bin/bwrap", "/tmp/f", ForeignRequest(), HostLayout.MergedUsr, BwrapFeatures.All)); [Fact] public void No_memory_limit_is_written_as_unlimited() { var line = Line(new SandboxRequest { Executable = "true", WorkspacePath = Workspace, Limits = new SandboxLimits { MaxMemoryBytes = 0 } }); line[4].ShouldBe("unlimited"); } [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"); } }