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 },