From 0a1da8817b710c484e741644985982b78ebf1e3c Mon Sep 17 00:00:00 2001 From: "Mars.P" Date: Sun, 27 Sep 2026 23:30:58 +0800 Subject: [PATCH] Probe bubblewrap with the argv a launch runs The availability probe ran a hand-picked subset of flags with no --proc, --dev or --unshare-net. On a host that allows user namespaces but masks /proc (Docker or a Kubernetes pod with seccomp Unconfined and the default masked paths or procMount) that subset passed, so the worker reported confinement while every real launch died in bwrap with "Can't mount proc". RequireConfinement could not refuse such a host, and the run record claimed isolation the launch never got. Under Docker's full defaults nothing changes: the default seccomp profile already blocks the user namespace, so both probes report no-userns. The probe now runs BuildArgs of the default tier's own plan (network severed, running true), so "available" means the launch argv runs here. Only when bwrap starts and refuses that argv does it re-run the old minimal user-namespace argv, to tell no-userns apart from the new mounts-denied reason. The two are told apart by which argv ran, not by parsing bwrap's stderr, because the fixes differ: allow user namespaces versus unmask /proc. A masked-/proc host goes from "every launch fails" to an honest Unconfined record, or a refusal under RequireConfinement. One host loses confinement it had: one that allows user namespaces but denies network namespaces (user.max_net_namespaces=0, or a profile that denies only CLONE_NEWNET). Its network-off launches already died in bwrap; now the probe fails as well, so its Trusted runs, which share the network and did confine, run unconfined, and the record names the wall mounts-denied. That is accepted, because "available" answers for the default tier and no probed deployment posture denies only network namespaces. The refusal and the RequireConfinement docs name the network namespace, so an operator on such a host is not sent to /proc. The reason column has no constraint, so the new value needs no migration. It reaches user-visible text only through the network-posture sentence, which the Room shows as its network stat line and the shared fixture pins. --- .github/workflows/sandbox-isolation.yml | 11 +- backend/src/CodeSpace.Api/appsettings.json | 2 +- .../Sandbox/Isolation/BubblewrapSandbox.cs | 123 +++++++++++++----- .../Settings/RuntimeSettings.cs | 5 +- .../Agents/SandboxConfinement.cs | 5 +- .../Sessions/RoomProjectorFlowTests.cs | 1 + .../BubblewrapProbeE2ETests.cs | 81 ++++++++++++ .../Agents/AgentAutonomyPolicyTests.cs | 1 + .../Agents/NetworkPostureWordingDriftTests.cs | 6 +- .../Workflows/BubblewrapSandboxTests.cs | 72 ++++++++++ frontend/src/lib/networkPosture.fixture.json | 5 + 11 files changed, 268 insertions(+), 44 deletions(-) create mode 100644 backend/tests/CodeSpace.SandboxTests/BubblewrapProbeE2ETests.cs diff --git a/.github/workflows/sandbox-isolation.yml b/.github/workflows/sandbox-isolation.yml index 10d54c328..91d4b0f29 100644 --- a/.github/workflows/sandbox-isolation.yml +++ b/.github/workflows/sandbox-isolation.yml @@ -224,8 +224,8 @@ jobs: executed=$(grep -oE 'executed="[0-9]+"' "$trx" | head -1 | grep -oE '[0-9]+') passed=$(grep -oE 'passed="[0-9]+"' "$trx" | head -1 | grep -oE '[0-9]+') echo "executed=${executed:-0} passed=${passed:-0}" - if [ "${executed:-0}" -lt 67 ]; then - echo "::error::Expected >=67 sandbox isolation tests to run (bwrap/prlimit confinement + cap-drop + cgroup-namespace re-root + egress-allowlist filter + cgroup resource cap + durable-launch cgroup wiring + argv/envp per-string kernel ceiling + a prompt past it riding stdin + a read-only workspace mount + a network-off run sealed to its broker + a read-only reviewer reading its diff with the real CLIs), but only ${executed:-0} did — the Category=Sandbox filter matched too few (trait regression?). If a case was deliberately removed, lower this number in the same PR." + if [ "${executed:-0}" -lt 69 ]; then + echo "::error::Expected >=69 sandbox isolation tests to run (bwrap/prlimit confinement + cap-drop + cgroup-namespace re-root + egress-allowlist filter + cgroup resource cap + durable-launch cgroup wiring + argv/envp per-string kernel ceiling + a prompt past it riding stdin + a read-only workspace mount + a network-off run sealed to its broker + a read-only reviewer reading its diff with the real CLIs + a bwrap probe that runs the launch argv), but only ${executed:-0} did — the Category=Sandbox filter matched too few (trait regression?). If a case was deliberately removed, lower this number in the same PR." exit 1 fi @@ -265,6 +265,13 @@ jobs: assert len(cases) == rows and all(r.get('outcome') == 'Passed' for r in cases), f'{method}: all {rows} case(s) must pass' print('All 7 sealed-egress arms ran and passed.') + # The bwrap probe E2E returns early off root, which reads as Passed; require each arm's marker. + for arm in ('masked-proc', 'unmasked-proc'): + assert f'[bwrap-probe-e2e] ran {arm}' in text, f'bwrap probe E2E arm "{arm}" did not run — this lane is root with bwrap and util-linux, so it must stage both' + cases = [r for r in results if 'BubblewrapProbeE2ETests.The_probe_runs_the_launch_argv_so_a_masked_proc_is_reported_unavailable' in r.get('testName', '')] + assert len(cases) == 2 and all(r.get('outcome') == 'Passed' for r in cases), 'both bwrap probe arms must pass' + print('Both bwrap probe arms ran and passed.') + print('All 10 batch/stream kernel cases passed.') PY diff --git a/backend/src/CodeSpace.Api/appsettings.json b/backend/src/CodeSpace.Api/appsettings.json index 3da3db315..2f8420aad 100644 --- a/backend/src/CodeSpace.Api/appsettings.json +++ b/backend/src/CodeSpace.Api/appsettings.json @@ -48,7 +48,7 @@ }, "AllowedHosts": "*", "Sandbox": { - "_doc": "RequireConfinement makes an agent run REFUSE to start rather than run unconfined when bubblewrap or unprivileged user namespaces are unavailable. Off here because a host that cannot confine (macOS development, a container without userns) would otherwise fail every run; a multi-tenant deployment on a userns-capable pod turns it on. CgroupRoot is a DELEGATED cgroup-v2 subtree the durable launch creates per-run leaves under — blank means no resource cap is applied. MaxAutonomy is this deployment's autonomy ceiling (Confined | Standard | Trusted | Unleashed) — the tier no AGENT on this host may exceed, however it is launched. It bounds every site that materializes an agent's own tier or egress: the launch route and an API client posting autonomy directly, an authored or replayed agent.run node, a supervisor's per-agent spawns (one authored profile tier, N sandboxes), a benchmark/qualification round's caller-supplied verifier tier, and agent.run_command's raw network flag (that lane has no tier at all, so the ceiling's derived network posture narrows the flag instead). It deliberately does NOT bound the platform's OWN git transport — clone, fetch, ls-remote, push — which is engine I/O rather than agent-directed egress; severing it would fail every run instead of confining one. Unleashed here means no deployment bound beyond the per-route ceilings, and is the default rather than the safer-looking Trusted because the two grant identical sandbox knobs but NOT identical governance: AgentToolGate lets only Unleashed run a destructive tool unattended, so a Trusted default would silently re-gate every authored node pinned to Unleashed. Lower it (Standard denies network to every tier) by committing a value and shipping the PR.", + "_doc": "RequireConfinement makes an agent run REFUSE to start rather than run unconfined when bubblewrap, unprivileged user namespaces, or the launch's own /proc, /dev or network namespace are unavailable (for example a masked /proc). Off here because a host that cannot confine (macOS development, a container without userns) would otherwise fail every run; a multi-tenant deployment on a userns-capable pod turns it on. CgroupRoot is a DELEGATED cgroup-v2 subtree the durable launch creates per-run leaves under — blank means no resource cap is applied. MaxAutonomy is this deployment's autonomy ceiling (Confined | Standard | Trusted | Unleashed) — the tier no AGENT on this host may exceed, however it is launched. It bounds every site that materializes an agent's own tier or egress: the launch route and an API client posting autonomy directly, an authored or replayed agent.run node, a supervisor's per-agent spawns (one authored profile tier, N sandboxes), a benchmark/qualification round's caller-supplied verifier tier, and agent.run_command's raw network flag (that lane has no tier at all, so the ceiling's derived network posture narrows the flag instead). It deliberately does NOT bound the platform's OWN git transport — clone, fetch, ls-remote, push — which is engine I/O rather than agent-directed egress; severing it would fail every run instead of confining one. Unleashed here means no deployment bound beyond the per-route ceilings, and is the default rather than the safer-looking Trusted because the two grant identical sandbox knobs but NOT identical governance: AgentToolGate lets only Unleashed run a destructive tool unattended, so a Trusted default would silently re-gate every authored node pinned to Unleashed. Lower it (Standard denies network to every tier) by committing a value and shipping the PR.", "RequireConfinement": false, "CgroupRoot": "", "MaxAutonomy": "Unleashed" diff --git a/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Isolation/BubblewrapSandbox.cs b/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Isolation/BubblewrapSandbox.cs index d4b12a319..371aeb3d2 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Isolation/BubblewrapSandbox.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Isolation/BubblewrapSandbox.cs @@ -20,9 +20,10 @@ namespace CodeSpace.Core.Services.Agents.Sandbox.Isolation; /// syscall filtering is the remaining hardening. /// /// Availability is probed, never assumed (): Linux + a bwrap binary + -/// a WORKING unprivileged user namespace (a real confined true must exit 0). When unavailable — macOS dev, -/// no bwrap, or a host that forbids unprivileged userns — the caller runs the command UNCONFINED. That is -/// an honestly-degraded trust mode that must be surfaced, never silently presented as isolation. +/// the argv a launch builds actually running (a confined true under must exit 0). When +/// unavailable — macOS dev, no bwrap, a host that forbids unprivileged userns, or one that denies the launch's +/// mounts or network namespace — the caller runs the command UNCONFINED. That is an honestly-degraded trust mode that +/// must be surfaced, never silently presented as isolation. /// public static class BubblewrapSandbox { @@ -46,10 +47,10 @@ public static class BubblewrapSandbox private static readonly Lazy LazyProbe = new(Probe); - /// The resolved bwrap path when this host can confine (Linux + bwrap + working userns), else null. + /// The resolved bwrap path when this host can confine (Linux + bwrap + the launch argv runs), else null. public static string? Available => LazyProbe.Value.Path; - /// WHY this host cannot confine — one of SandboxConfinement's reason constants — or null when it can. The probe already distinguishes the three cases; keeping the distinction is what lets a run's record say which wall it hit instead of an unactionable "unavailable". + /// WHY this host cannot confine — one of SandboxConfinement's reason constants — or null when it can. The probe already distinguishes the four cases; keeping the distinction is what lets a run's record say which wall it hit instead of an unactionable "unavailable". public static string? UnavailableReason => LazyProbe.Value.Reason; /// Whether this deployment mandates confinement (Sandbox:RequireConfinement) — read live off the bound settings so it tracks configuration, not a captured copy. @@ -65,7 +66,7 @@ public static void EnsureSatisfiable(string? available, bool required) if (required && available is null) throw new InvalidOperationException( "Sandbox isolation is required (Sandbox:RequireConfinement) but bubblewrap is unavailable on this host " + - "(not Linux, bwrap not installed, or unprivileged user namespaces denied). Refusing to run the agent unconfined."); + "(not Linux, bwrap not installed, unprivileged user namespaces denied, or the launch's mounts or network namespace denied). Refusing to run the agent unconfined."); } /// @@ -208,48 +209,100 @@ private static IEnumerable DistinctNonEmpty(IEnumerable paths) } /// - /// Resolve bwrap only on Linux, and only if a TRIVIAL confined process actually runs — a host that forbids - /// unprivileged user namespaces (restrictive sysctl / seccomp) has bwrap on PATH but cannot confine, so we must - /// fall back to unconfined rather than fail every run. Result is cached for the process lifetime. + /// The plan the availability probe confines: the default tier's own launch shape, network severed, running + /// true. The probe runs of it rather than a hand-picked subset of flags, so + /// "available" means a launch's argv runs here. A host that masks /proc lets a bare user namespace through + /// and then refuses every launch's fresh --proc. + /// + /// The price of probing the default tier rather than the loosest one: a host that grants user namespaces but + /// denies network namespaces fails this argv too, reads as , and + /// runs even its network-sharing launches unconfined, though those would have confined there. + /// + internal static readonly BwrapPlan ProbePlan = new() { Command = "true", ShareNetwork = false }; + + /// + /// Run only after 's argv was refused, to name the wall it hit: a user namespace with the + /// flags a launch depends on (--cap-drop, --unshare-cgroup-try, so a bwrap too old for them still + /// fails here) but none of the launch's own mounts. Passing is ; + /// failing too is . Told apart by which argv ran, never by + /// reading bwrap's stderr. + /// + internal static readonly IReadOnlyList UserNamespaceProbeArgs = ["--unshare-user", "--unshare-pid", "--unshare-cgroup-try", "--cap-drop", "ALL", "--ro-bind", "/", "/", "--", "true"]; + + /// + /// Resolve bwrap only on Linux, and only if the argv a launch builds actually runs a confined true. A host + /// that forbids unprivileged user namespaces, or masks the /proc a launch mounts, has bwrap on PATH but + /// cannot confine, so we must fall back to unconfined rather than fail every run. Result is cached for the process + /// lifetime. /// - private static BwrapProbeResult Probe() + private static BwrapProbeResult Probe() => + OperatingSystem.IsLinux() ? ProbeAt(ConfiguredCommand()) : BwrapProbeResult.Unavailable(SandboxConfinement.ReasonNotLinux); + + private static string ConfiguredCommand() => Environment.GetEnvironmentVariable(CommandEnvVar) is { Length: > 0 } p ? p : DefaultCommand; + + /// Probe the bwrap at now, uncached. is this, run once per process; internal so a real-kernel test can probe a host posture it staged. + internal static BwrapProbeResult ProbeAt(string path) => Classify(path, args => RunProbe(path, args)); + + /// + /// The probe's decision, pure over : the launch argv first, and only when bwrap started and + /// refused it, to say why. The reasons are distinct because the fixes are: + /// "install bwrap", "allow user namespaces", "unmask /proc". + /// + internal static BwrapProbeResult Classify(string path, Func, ProbeOutcome> run) => run(BuildArgs(ProbePlan)) switch { - if (!OperatingSystem.IsLinux()) return BwrapProbeResult.Unavailable(SandboxConfinement.ReasonNotLinux); + ProbeOutcome.Ran => BwrapProbeResult.Confining(path), + ProbeOutcome.Missing => BwrapProbeResult.Unavailable(SandboxConfinement.ReasonNoBubblewrap), + _ => BwrapProbeResult.Unavailable(WallTheLaunchHit(run)), + }; - var path = Environment.GetEnvironmentVariable(CommandEnvVar) is { Length: > 0 } p ? p : DefaultCommand; + private static string WallTheLaunchHit(Func, ProbeOutcome> run) => + run(UserNamespaceProbeArgs) == ProbeOutcome.Ran ? SandboxConfinement.ReasonMountsDenied : SandboxConfinement.ReasonNoUserNamespaces; + /// Run bwrap once with . A start that fails is ; a non-zero exit or a hang past the timeout is bwrap refusing to confine. + private static ProbeOutcome RunProbe(string path, IReadOnlyList args) + { try { - // Exercise the flags the real run depends on (--cap-drop, --unshare-cgroup-try), not just userns: a bwrap - // too old to know them must report UNAVAILABLE (→ unconfined fallback / fail-closed) rather than pass here - // and then die on every real launch with "unknown option". --unshare-cgroup-try is best-effort, so a host - // without cgroup namespaces still probes clean. - var psi = new ProcessStartInfo { FileName = path, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true }; - foreach (var arg in new[] { "--unshare-user", "--unshare-pid", "--unshare-cgroup-try", "--cap-drop", "ALL", "--ro-bind", "/", "/", "--", "true" }) - psi.ArgumentList.Add(arg); - - using var proc = Process.Start(psi); - if (proc is null) return BwrapProbeResult.Unavailable(SandboxConfinement.ReasonNoBubblewrap); - - if (!proc.WaitForExit(ProbeTimeoutMs)) - { - try { proc.Kill(entireProcessTree: true); } catch { /* best-effort */ } - return BwrapProbeResult.Unavailable(SandboxConfinement.ReasonNoUserNamespaces); - } - - // A NON-ZERO exit means bwrap ran and REFUSED to confine — denied unprivileged userns, or flags this - // build doesn't know. Distinct from the binary not being there at all, and the distinction is the whole - // point of recording a reason: one is "install bwrap", the other "allow user namespaces". - return proc.ExitCode == 0 ? BwrapProbeResult.Confining(path) : BwrapProbeResult.Unavailable(SandboxConfinement.ReasonNoUserNamespaces); + using var proc = Process.Start(ProbeStartInfo(path, args)); + + if (proc is null) return ProbeOutcome.Missing; + + if (proc.WaitForExit(ProbeTimeoutMs)) return proc.ExitCode == 0 ? ProbeOutcome.Ran : ProbeOutcome.Refused; + + try { proc.Kill(entireProcessTree: true); } catch { /* best-effort */ } + + return ProbeOutcome.Refused; } catch { - return BwrapProbeResult.Unavailable(SandboxConfinement.ReasonNoBubblewrap); // bwrap absent / not executable → unconfined fallback + return ProbeOutcome.Missing; // bwrap absent / not executable → unconfined fallback } } + private static ProcessStartInfo ProbeStartInfo(string path, IReadOnlyList args) + { + var psi = new ProcessStartInfo { FileName = path, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true }; + + foreach (var arg in args) psi.ArgumentList.Add(arg); + + return psi; + } + + /// What one probe run of bwrap showed. + internal enum ProbeOutcome + { + /// The confined true exited 0. + Ran, + + /// bwrap started and refused to confine: a non-zero exit, or a hang past the probe's timeout. + Refused, + + /// bwrap could not be started at all: absent, or not executable. + Missing, + } + /// The probe's two facts kept together — the resolved path when this host confines, else the reason it does not. Cached once, so the reason costs nothing beyond the probe already run. - private readonly record struct BwrapProbeResult(string? Path, string? Reason) + internal readonly record struct BwrapProbeResult(string? Path, string? Reason) { public static BwrapProbeResult Confining(string path) => new(path, null); diff --git a/backend/src/CodeSpace.Core/Settings/RuntimeSettings.cs b/backend/src/CodeSpace.Core/Settings/RuntimeSettings.cs index 2fb8e6fb3..b8e17eddd 100644 --- a/backend/src/CodeSpace.Core/Settings/RuntimeSettings.cs +++ b/backend/src/CodeSpace.Core/Settings/RuntimeSettings.cs @@ -26,8 +26,9 @@ public sealed record RuntimeSettings { /// /// Whether this deployment MANDATES sandbox confinement. On, an agent run refuses to start rather than run - /// unconfined when bubblewrap or unprivileged user namespaces are unavailable. Off is the default because a host - /// that cannot confine — macOS development, a container without userns — would otherwise fail every run. + /// unconfined when bubblewrap, unprivileged user namespaces, or the launch's own /proc, /dev or network namespace + /// are unavailable (for example a masked /proc). Off is the default because a host that cannot confine — macOS + /// development, a container without userns — would otherwise fail every run. /// public bool RequireSandboxConfinement { get; init; } diff --git a/backend/src/CodeSpace.Messages/Agents/SandboxConfinement.cs b/backend/src/CodeSpace.Messages/Agents/SandboxConfinement.cs index 6fcd45add..57d7deec6 100644 --- a/backend/src/CodeSpace.Messages/Agents/SandboxConfinement.cs +++ b/backend/src/CodeSpace.Messages/Agents/SandboxConfinement.cs @@ -34,9 +34,12 @@ public sealed record SandboxConfinement /// Linux, but no runnable bwrap binary (absent, or not executable). public const string ReasonNoBubblewrap = "no-bwrap"; - /// Linux with bwrap present, but the confinement probe failed — unprivileged user namespaces denied, or a bwrap too old for the flags a real launch needs. + /// Linux with bwrap present, but even a bare user namespace failed to confine — unprivileged user namespaces denied, or a bwrap too old for the flags a real launch needs. public const string ReasonNoUserNamespaces = "no-userns"; + /// Linux with bwrap and a working user namespace, but the launch's own argv is refused: its fresh /proc, /dev or network namespace. Typically a masked /proc (Docker's default masked paths, a Kubernetes pod's default procMount) or a profile that denies mounts. + public const string ReasonMountsDenied = "mounts-denied"; + public required SandboxConfinementOutcome Outcome { get; init; } /// Why the host could not confine — one of the Reason* constants. Null for every outcome but . diff --git a/backend/tests/CodeSpace.IntegrationTests/Sessions/RoomProjectorFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Sessions/RoomProjectorFlowTests.cs index f782228a7..527ab348c 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Sessions/RoomProjectorFlowTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Sessions/RoomProjectorFlowTests.cs @@ -2431,6 +2431,7 @@ private async Task SeedForcedStopDecisionAsync(Guid teamId, Guid runId, string r // says the rest of the loss with it, because an unconfined agent keeps the worker's own filesystem view. [InlineData(SandboxConfinementOutcome.Unconfined, false, SandboxConfinement.ReasonNoUserNamespaces, "Network: off (Standard) — OFF REQUESTED BUT UNCONFINED: this host cannot sever egress (no-userns)" + AgentAutonomyPolicy.UnconfinedIsolationCaveat)] [InlineData(SandboxConfinementOutcome.Unconfined, false, SandboxConfinement.ReasonNotLinux, "Network: off (Standard) — OFF REQUESTED BUT UNCONFINED: this host cannot sever egress (not-linux)" + AgentAutonomyPolicy.UnconfinedIsolationCaveat)] + [InlineData(SandboxConfinementOutcome.Unconfined, false, SandboxConfinement.ReasonMountsDenied, "Network: off (Standard) — OFF REQUESTED BUT UNCONFINED: this host cannot sever egress (mounts-denied)" + AgentAutonomyPolicy.UnconfinedIsolationCaveat)] public async Task The_room_states_the_posture_the_runs_own_agents_recorded(SandboxConfinementOutcome outcome, bool severed, string? reason, string expected) { var (teamId, _) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); diff --git a/backend/tests/CodeSpace.SandboxTests/BubblewrapProbeE2ETests.cs b/backend/tests/CodeSpace.SandboxTests/BubblewrapProbeE2ETests.cs new file mode 100644 index 000000000..68783031f --- /dev/null +++ b/backend/tests/CodeSpace.SandboxTests/BubblewrapProbeE2ETests.cs @@ -0,0 +1,81 @@ +using System.Runtime.Versioning; +using CodeSpace.Core.Services.Agents.Sandbox.Isolation; +using CodeSpace.Messages.Agents; +using Shouldly; +using Xunit.Abstractions; + +namespace CodeSpace.SandboxTests; + +/// +/// 🟢 Sandbox E2E (high fidelity, Rule 12): the production availability probe () +/// runs the real bwrap on this kernel, started inside a private mount namespace that masks /proc the way +/// Docker's default masked paths (and a Kubernetes pod's default procMount) do. On such a host a bare user +/// namespace still works while the fresh /proc every launch mounts is refused, so a probe that tested less +/// than the launch argv reported it as confining and every launch then died inside bwrap. +/// +/// Staging the namespace needs root, so it runs for real only in the privileged sandbox-isolation job; +/// elsewhere it returns. Every arm that ran prints , which the lane requires, so a silent +/// return can never pass for coverage. +/// +[Trait("Category", "Sandbox")] +public sealed class BubblewrapProbeE2ETests(ITestOutputHelper output) +{ + /// Printed by every arm that actually staged its host; the sandbox lane requires one per arm. + public const string RanMarker = "[bwrap-probe-e2e] ran"; + + [Theory] + // /dev/null over /proc/kcore: the probe must report the host unavailable, and name the launch's mounts as the wall. + [InlineData(true, SandboxConfinement.ReasonMountsDenied)] + // The same staging without the overmount: the control that proves the wrapper is not what refused. + [InlineData(false, null)] + public void The_probe_runs_the_launch_argv_so_a_masked_proc_is_reported_unavailable(bool maskProc, string? expectedReason) + { + if (BubblewrapSandbox.Available is null) + { + BubblewrapSandbox.IsRequired.ShouldBeFalse("Sandbox:RequireConfinement is set but this host cannot sandbox (bwrap/userns) — the E2E cannot stage a host posture here"); + return; + } + + if (!OperatingSystem.IsLinux() || !Environment.IsPrivilegedProcess) return; + + File.Exists("/proc/kcore").ShouldBeTrue("the staging masks /proc/kcore as Docker does; this kernel has none, so pick another of Docker's masked paths under /proc"); + + using var host = new StagedProcHost(maskProc); + + var probe = BubblewrapSandbox.ProbeAt(host.BwrapPath); + + probe.Reason.ShouldBe(expectedReason, + customMessage: $"masked /proc={maskProc}: the probe must answer for the argv a launch runs. Reproduce by hand as root: unshare -m sh -c 'mount --bind /dev/null /proc/kcore; bwrap --unshare-user --unshare-net --proc /proc --ro-bind / / -- true; echo exit=$?'"); + probe.Path.ShouldBe(expectedReason is null ? host.BwrapPath : null, "a host is available exactly when it has no unconfinable reason"); + + output.WriteLine($"{RanMarker} {(maskProc ? "masked-proc" : "unmasked-proc")} reason={probe.Reason ?? "none"}"); + } + + /// + /// A bwrap that starts in its own private mount namespace, with /dev/null bound over /proc/kcore when + /// asked, and is otherwise this host's real bwrap. The overmount lives only in that namespace (private propagation), + /// so nothing reaches the host; the script's GUID-named directory is removed on dispose (Rule 12.2/12.3). + /// + [SupportedOSPlatform("linux")] + private sealed class StagedProcHost : IDisposable + { + private readonly string _dir = Path.Combine(Path.GetTempPath(), "cs-bwrap-probe-" + Guid.NewGuid().ToString("N")); + + public StagedProcHost(bool maskProc) + { + Directory.CreateDirectory(_dir); + File.WriteAllText(BwrapPath, Script(maskProc)); + File.SetUnixFileMode(BwrapPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + public string BwrapPath => Path.Combine(_dir, "staged-bwrap"); + + private static string Script(bool maskProc) => + $"#!/bin/sh\nexec unshare --mount --propagation private /bin/sh -c '{(maskProc ? "mount --bind /dev/null /proc/kcore && " : "")}exec \"$0\" \"$@\"' {BubblewrapSandbox.Available} \"$@\"\n"; + + public void Dispose() + { + try { Directory.Delete(_dir, recursive: true); } catch { /* best-effort */ } + } + } +} diff --git a/backend/tests/CodeSpace.UnitTests/Agents/AgentAutonomyPolicyTests.cs b/backend/tests/CodeSpace.UnitTests/Agents/AgentAutonomyPolicyTests.cs index 79463cb15..abf6c98f8 100644 --- a/backend/tests/CodeSpace.UnitTests/Agents/AgentAutonomyPolicyTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Agents/AgentAutonomyPolicyTests.cs @@ -165,6 +165,7 @@ public void DescribeNetwork_never_claims_an_unqualified_off() [InlineData(SandboxConfinementOutcome.Unconfined, SandboxConfinement.ReasonNotLinux, false, "Network: off (Standard) — OFF REQUESTED BUT UNCONFINED: this host cannot sever egress (not-linux); cross-team isolation not enforced")] [InlineData(SandboxConfinementOutcome.Unconfined, SandboxConfinement.ReasonNoBubblewrap, false, "Network: off (Standard) — OFF REQUESTED BUT UNCONFINED: this host cannot sever egress (no-bwrap); cross-team isolation not enforced")] [InlineData(SandboxConfinementOutcome.Unconfined, SandboxConfinement.ReasonNoUserNamespaces, false, "Network: off (Standard) — OFF REQUESTED BUT UNCONFINED: this host cannot sever egress (no-userns); cross-team isolation not enforced")] + [InlineData(SandboxConfinementOutcome.Unconfined, SandboxConfinement.ReasonMountsDenied, false, "Network: off (Standard) — OFF REQUESTED BUT UNCONFINED: this host cannot sever egress (mounts-denied); cross-team isolation not enforced")] // A runner that attempts no confinement at all is in the same honest bucket as one that could not. [InlineData(SandboxConfinementOutcome.NotApplicable, null, false, "Network: off (Standard) — OFF REQUESTED BUT UNCONFINED: this runner applies no confinement; cross-team isolation not enforced")] public void DescribeNetwork_resolves_the_hedge_from_the_runs_own_confinement_record(SandboxConfinementOutcome outcome, string? reason, bool severed, string expected) diff --git a/backend/tests/CodeSpace.UnitTests/Agents/NetworkPostureWordingDriftTests.cs b/backend/tests/CodeSpace.UnitTests/Agents/NetworkPostureWordingDriftTests.cs index 1fb6f11b4..8d4fd94b0 100644 --- a/backend/tests/CodeSpace.UnitTests/Agents/NetworkPostureWordingDriftTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Agents/NetworkPostureWordingDriftTests.cs @@ -73,11 +73,11 @@ public void The_shared_fixture_says_exactly_what_a_RECORDED_posture_says() [Fact] public void The_shared_fixture_covers_every_unconfinable_reason() { - // A fixture that only sampled one reason would let the other two drift into an unhelpful "unavailable". The - // reason IS the actionable half of an unconfined verdict ("install bwrap" vs "allow user namespaces"). + // A fixture that only sampled one reason would let the others drift into an unhelpful "unavailable". The + // reason IS the actionable half of an unconfined verdict ("install bwrap" vs "allow user namespaces" vs "unmask /proc"). var lines = ReadConfinementFixture().Select(c => c.Line).ToList(); - foreach (var reason in new[] { SandboxConfinement.ReasonNotLinux, SandboxConfinement.ReasonNoBubblewrap, SandboxConfinement.ReasonNoUserNamespaces }) + foreach (var reason in new[] { SandboxConfinement.ReasonNotLinux, SandboxConfinement.ReasonNoBubblewrap, SandboxConfinement.ReasonNoUserNamespaces, SandboxConfinement.ReasonMountsDenied }) lines.ShouldContain(l => l.Contains($"({reason})"), $"no case pins the '{reason}' wording"); } diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/BubblewrapSandboxTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/BubblewrapSandboxTests.cs index 014c1638e..a2af06b61 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/BubblewrapSandboxTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/BubblewrapSandboxTests.cs @@ -27,6 +27,7 @@ public sealed class BubblewrapSandboxTests [InlineData(null, SandboxConfinement.ReasonNotLinux, false, SandboxConfinementOutcome.Unconfined, SandboxConfinement.ReasonNotLinux, false)] [InlineData(null, SandboxConfinement.ReasonNoBubblewrap, false, SandboxConfinementOutcome.Unconfined, SandboxConfinement.ReasonNoBubblewrap, false)] [InlineData(null, SandboxConfinement.ReasonNoUserNamespaces, true, SandboxConfinementOutcome.Unconfined, SandboxConfinement.ReasonNoUserNamespaces, false)] + [InlineData(null, SandboxConfinement.ReasonMountsDenied, false, SandboxConfinementOutcome.Unconfined, SandboxConfinement.ReasonMountsDenied, false)] // An unconfined run NEVER records a severed egress, whatever the tier asked for — that is the entire dishonesty // this record ends, so a network-off request on an unconfinable host must still come back NetworkSevered:false. [InlineData(null, null, false, SandboxConfinementOutcome.Unconfined, SandboxConfinement.ReasonNoBubblewrap, false)] @@ -215,6 +216,77 @@ public void ReadOnlyRootDirs_are_pinned() => public void CommandEnvVar_is_pinned() => BubblewrapSandbox.CommandEnvVar.ShouldBe("CODESPACE_BWRAP_PATH"); + [Fact] + public void The_probe_runs_the_argv_a_network_off_launch_builds() + { + // A hand-picked subset of flags passed on hosts that mask /proc and then refused every real launch's fresh + // --proc. The probe must ask the SAME builder a launch does, for the default tier's shape: network severed. + var runs = new List>(); + + BubblewrapSandbox.Classify("/usr/bin/bwrap", args => { runs.Add(args); return Outcome("Ran"); }); + + var probed = runs.ShouldHaveSingleItem("a launch argv that runs needs no second opinion"); + + probed.ShouldBe(BubblewrapSandbox.BuildArgs(BubblewrapSandbox.ProbePlan), "the probe argv must be the launch builder's own output, not a copy of some of its flags"); + Adjacent(probed, "--proc", "/proc").ShouldBeTrue("a fresh /proc is the mount a masked host refuses"); + Adjacent(probed, "--dev", "/dev").ShouldBeTrue(); + probed.ShouldContain("--unshare-net", customMessage: "the default tier severs the network, so the probe must build a network namespace too"); + } + + [Theory] + // The launch argv ran: the host confines, and nothing else is asked. + [InlineData("Ran", null, true, null, 1)] + // bwrap could not even start: there is no binary to confine with. + [InlineData("Missing", null, false, SandboxConfinement.ReasonNoBubblewrap, 1)] + // bwrap refused the launch but ran a bare user namespace: the launch's own mounts are this host's wall. + [InlineData("Refused", "Ran", false, SandboxConfinement.ReasonMountsDenied, 2)] + // bwrap refused both: no working user namespace, or a bwrap too old for the flags a launch needs. + [InlineData("Refused", "Refused", false, SandboxConfinement.ReasonNoUserNamespaces, 2)] + public void The_probe_names_the_wall_a_refused_launch_hit(string launch, string? userNamespace, bool confines, string? expectedReason, int expectedRuns) + { + var runs = new List>(); + var answers = new Queue(new[] { launch, userNamespace }); + + var probe = BubblewrapSandbox.Classify("/usr/bin/bwrap", args => { runs.Add(args); return Outcome(answers.Dequeue()!); }); + + probe.Path.ShouldBe(confines ? "/usr/bin/bwrap" : null); + probe.Reason.ShouldBe(expectedReason, "the reason is read off WHICH argv ran, never off bwrap's stderr"); + runs.Count.ShouldBe(expectedRuns, "the bare user-namespace argv runs only to name the wall of a launch bwrap refused"); + + if (expectedRuns == 2) runs[1].ShouldBe(BubblewrapSandbox.UserNamespaceProbeArgs); + } + + [Fact] + public void The_fallback_probe_asks_for_a_user_namespace_and_none_of_the_launch_mounts() + { + // Its passing is what names mounts-denied, so it must hold none of the mounts a launch adds, and still the + // flags a launch depends on — or a bwrap too old for them would read as a host that denies mounts. + var fallback = BubblewrapSandbox.UserNamespaceProbeArgs; + + fallback.ShouldContain("--unshare-user"); + Adjacent(fallback, "--cap-drop", "ALL").ShouldBeTrue(); + fallback.ShouldContain("--unshare-cgroup-try"); + fallback.ShouldNotContain("--proc"); + fallback.ShouldNotContain("--dev"); + fallback.ShouldNotContain("--unshare-net"); + } + + [Theory] + [InlineData("not Linux")] // not-linux + [InlineData("bwrap not installed")] // no-bwrap + [InlineData("unprivileged user namespaces denied")] // no-userns + // mounts-denied: the refused launch argv also builds a network namespace, so a host that denies only that lands here too. + [InlineData("the launch's mounts or network namespace denied")] + public void The_refusal_names_every_wall_the_probe_can_report(string cause) + { + var refusal = Should.Throw(() => BubblewrapSandbox.EnsureSatisfiable(available: null, required: true)); + + refusal.Message.ShouldContain(cause, customMessage: "the refusal is the operator's only clue under RequireConfinement, so it must name the wall the probe could have hit"); + } + + /// The probe outcome a row names. Carried through InlineData as its NAME because the enum is internal to the assembly under test and cannot appear in a public test signature; a typo throws here rather than passing something else. + private static BubblewrapSandbox.ProbeOutcome Outcome(string name) => Enum.Parse(name); + private static bool Adjacent(IReadOnlyList a, string flag, string value) { diff --git a/frontend/src/lib/networkPosture.fixture.json b/frontend/src/lib/networkPosture.fixture.json index 6bc99ad2e..36e07489b 100644 --- a/frontend/src/lib/networkPosture.fixture.json +++ b/frontend/src/lib/networkPosture.fixture.json @@ -62,6 +62,11 @@ "confinement": { "outcome": "Unconfined", "reason": "no-userns", "networkSevered": false }, "line": "Network: clamped off by policy (ceiling Standard) — OFF REQUESTED BUT UNCONFINED: this host cannot sever egress (no-userns); cross-team isolation not enforced" }, + { + "effective": "Standard", "ceiling": "Trusted", "deployment": "Unleashed", + "confinement": { "outcome": "Unconfined", "reason": "mounts-denied", "networkSevered": false }, + "line": "Network: off (Standard) — OFF REQUESTED BUT UNCONFINED: this host cannot sever egress (mounts-denied); cross-team isolation not enforced" + }, { "effective": "Standard", "ceiling": "Standard", "deployment": "Unleashed", "confinement": { "outcome": "Confined", "networkSevered": true },