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