diff --git a/.github/workflows/sandbox-isolation.yml b/.github/workflows/sandbox-isolation.yml index 291846ada..cf04a9b90 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 59 ]; then - echo "::error::Expected >=59 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 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 65 ]; then + echo "::error::Expected >=65 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." exit 1 fi @@ -249,14 +249,22 @@ jobs: # The reviewer E2E returns early when it is not armed, and an early return reads as Passed — so a passing # outcome is not evidence it ran. Each arm prints a marker when it really did; require every one. text = open('backend/TestResults/sandbox.trx', encoding='utf-8').read() - arms = ('read-diff claude-code Confined', 'read-diff codex-cli Confined', 'read-diff codex-cli Standard', 'codex-resume-stand-down', 'write-refused claude-code', 'write-refused codex-cli', 'standard-codex-writes', 'network-off claude-code', 'network-off codex-cli') + arms = ('read-diff claude-code Confined', 'read-diff codex-cli Confined', 'read-diff codex-cli Standard', 'codex-resume-stand-down', 'write-refused claude-code', 'write-refused codex-cli', 'standard-codex-writes', 'network-off-sealed claude-code', 'network-off-sealed codex-cli') for arm in arms: assert f'[review-diff-e2e] ran {arm}' in text, f'reviewer E2E arm "{arm}" did not run — check CODESPACE_REQUIRE_REVIEW_CLIS and the CLI install step' - for method, rows in (('A_read_only_claude_reviewer_reads_the_diff_between_two_commits_with_git', 1), ('A_codex_reviewer_reads_the_diff_with_git_wherever_it_runs', 2), ('The_pinned_codex_accepts_the_resume_spelling_of_its_stand_down', 1), ('A_confined_reviewer_cannot_write_its_workspace', 2), ('A_standard_codex_writes_its_workspace_under_our_confinement_but_not_the_system_root', 1), ('A_network_off_reviewer_under_confinement_cannot_reach_its_model', 2)): + for method, rows in (('A_read_only_claude_reviewer_reads_the_diff_between_two_commits_with_git', 1), ('A_codex_reviewer_reads_the_diff_with_git_wherever_it_runs', 2), ('The_pinned_codex_accepts_the_resume_spelling_of_its_stand_down', 1), ('A_confined_reviewer_cannot_write_its_workspace', 2), ('A_standard_codex_writes_its_workspace_under_our_confinement_but_not_the_system_root', 1), ('A_network_off_reviewer_reaches_its_model_through_the_sealed_namespace', 2)): cases = [r for r in results if 'ReviewerReadsItsDiffE2ETests.' + method in r.get('testName', '')] assert len(cases) == rows and all(r.get('outcome') == 'Passed' for r in cases), f'{method}: all {rows} case(s) must pass' print(f'All {len(arms)} reviewer E2E arms ran and passed.') + # The sealed-egress E2E returns early on a host that cannot seal, which reads as Passed; require each arm's marker. + for arm in ('durable', 'non-durable', 'ipv6', 'restart-reissue', 'policy-route-discard'): + assert f'[sealed-egress-e2e] ran {arm}' in text, f'sealed-egress E2E arm "{arm}" did not run — this lane is root with bwrap, ip and nft, so it must seal' + for method, rows in (('A_network_off_brokered_run_reaches_its_broker_and_nothing_else', 2), ('A_sealed_namespace_drops_the_gateway_over_ipv6_link_local_too', 1), ('A_30_still_held_by_a_run_that_outlived_its_worker_is_not_handed_to_the_next_run', 1), ('A_host_whose_policy_rule_discards_the_run_s_replies_fails_the_setup_and_leaks_nothing', 1)): + cases = [r for r in results if 'SealedEgressE2ETests.' + method in r.get('testName', '')] + assert len(cases) == rows and all(r.get('outcome') == 'Passed' for r in cases), f'{method}: all {rows} case(s) must pass' + print('All 5 sealed-egress arms ran and passed.') + print('All 10 batch/stream kernel cases passed.') PY diff --git a/backend/Dockerfile.worker b/backend/Dockerfile.worker index b4667e53a..bf08929d6 100644 --- a/backend/Dockerfile.worker +++ b/backend/Dockerfile.worker @@ -31,6 +31,11 @@ # CAP_NET_ADMIN (and run the egress path privileged enough to write the sysctl) on any deployment that relies on # allowlisted egress; a pod that does not is still correct, it just gets Denied instead of Filtered. # +# The same namespace machinery SEALS a network-off run whose model is brokered to that broker (no route, no NAT, no +# DNS, one gateway port). It is taken only where bubblewrap confines AND FilteredEgressNetns.CanSeal has proved, by +# building a throwaway namespace, that this process may: root + CAP_NET_ADMIN + CAP_SYS_ADMIN, no sysctl needed. A +# pod without them keeps severing such a run — which also severs it from its broker, so it reaches no model. +# # CONFINEMENT ARMING: bubblewrap is installed here so the capability is PRESENT, but the fail-closed guard # Sandbox:RequireConfinement is left to the DEPLOYMENT to arm (k8s pod / compose) on a host that grants user # namespaces — hardcoding it in the image would fail-close every run on an environment where in-container userns diff --git a/backend/src/CodeSpace.Core/Services/Agents/AgentAutonomyPolicy.cs b/backend/src/CodeSpace.Core/Services/Agents/AgentAutonomyPolicy.cs index 1c09a0d46..a5684c428 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/AgentAutonomyPolicy.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/AgentAutonomyPolicy.cs @@ -179,6 +179,9 @@ public static string DescribeNetwork(AgentAutonomyLevel effective, AgentAutonomy return WithModelCredentialPosture($"Network: off ({effective}){qualifier}", confinement); } + /// What an "off" run sealed to its model broker adds to the sentence: the one route it kept. + public const string SealedToBrokerQualifier = " — confined: egress sealed to the run's model broker"; + /// The write posture derived from the same permission row the process runner receives. Before launch there is no confinement record, so read-only is explicitly qualified rather than presented as an OS guarantee. public static string DescribeWrite(AgentAutonomyLevel effective) => Derive(effective).WriteScope switch { @@ -230,11 +233,13 @@ public static string DescribeApproval(AgentAutonomyLevel effective) /// materially different fact from a severed namespace and must not read like a milder version of it. /// is such a run too — no confinement was even attempted. /// Both unconfined verdicts also carry : egress is the loudest thing an - /// unconfined run loses, but not the only one. + /// unconfined run loses, but not the only one. A run sealed to its broker holds a per-run /30 exactly as an + /// allowlisted one does, so it carries the same host subnet caveat where this host has proved it. /// private static string OffQualifier(SandboxConfinement? confinement) => confinement switch { null => ConfinementCaveat, + { Outcome: SandboxConfinementOutcome.Confined, EgressSealedToBroker: true } => WithHostSubnetPosture(SealedToBrokerQualifier, EgressSubnetAllocator.ObservedHostDegradation), { Outcome: SandboxConfinementOutcome.Confined, NetworkSevered: true } => " — confined: egress severed", { Outcome: SandboxConfinementOutcome.Confined } => " — confined, but egress was NOT severed", { Outcome: SandboxConfinementOutcome.Unconfined } c => $" — OFF REQUESTED BUT UNCONFINED: this host cannot sever egress ({c.Reason ?? "unknown"}){UnconfinedIsolationCaveat}", diff --git a/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs b/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs index cfd38f6b1..346920cd8 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs @@ -460,7 +460,9 @@ public async Task ExecuteAsync(Guid agentRunId, CancellationToken cancellationTo // receives the governed tools the endpoint serves (today the harness projects ONLY task.Tools, so a restricted // run couldn't call them). Additive + tier-filtered; a no-op when the author named no tools (the CLI default // already reaches a declared MCP server's tools). Drives BuildInvocation off the augmented task. - SandboxSpec BuildSpec(AgentTask built) => HardenSpec(harness.BuildInvocation(AugmentToolsForMcp(built, mcp, mcpWiring)) with { Mcp = mcpWiring }, built, modelBaseUrl, modelProvider, workspaceProvision); + var hardening = new SpecHardening(modelBaseUrl, modelProvider, workspaceProvision, brokeredCredential?.RebindPort); + + SandboxSpec BuildSpec(AgentTask built) => HardenSpec(harness.BuildInvocation(AugmentToolsForMcp(built, mcp, mcpWiring)) with { Mcp = mcpWiring }, built, hardening); var spec = BuildSpec(effectiveTask); @@ -3764,9 +3766,9 @@ private async Task ResolveModelCredentialEnvAsync(Age // The non-secret base URL + provider tag flow out so a restricted (Allowlist) run can pin its model-API host // in the egress allowlist (B3.3b) — the UPSTREAM ones even under brokerage, deliberately: the allowlist is - // enforced inside the run's netns, the broker reaches the provider from the host outside it, and narrowing - // the allowlist to just the broker is a separate change (a brokered run keeping the provider host reachable - // loses nothing — the token it holds is refused there). DefaultModel flows out so a model-less ("auto") run + // enforced inside the run's netns, the broker reaches the provider from the host outside it, and a brokered + // run keeping the provider host reachable loses nothing — the token it holds is refused there. (A network-OFF + // brokered run is the one sealed to just its broker; see ApplySealedEgress.) DefaultModel flows out so a model-less ("auto") run // falls back to one of the credential's own models instead of the CLI default. All null when no credential // resolved. CredentialId names the ROW whose key this run authenticates with (null for the operator-global // key, which has no row) — D3 bounds an escalation's candidate models to exactly that row. @@ -4046,9 +4048,25 @@ internal static SandboxSpec ApplyResourceCeilings(SandboxSpec spec, AgentAutonom internal static SandboxSpec ApplyWriteScope(SandboxSpec spec, AgentPermissions permissions) => spec with { ReadOnlyWorkingDirectory = permissions.WriteScope != AgentWriteScope.Workspace }; - /// The harness invocation with every one of the executor's own spec post-processings applied — the egress posture, the write scope and the tier's resource ceilings. One name so the launch and each revise round cannot drift apart on which hardening they got. - private static SandboxSpec HardenSpec(SandboxSpec spec, AgentTask task, string? modelBaseUrl, string? modelProvider, WorkspaceProvisionRequest? workspace) => - ApplyResourceCeilings(ApplyWriteScope(ApplyEgressPolicy(spec, task.Permissions, modelBaseUrl, modelProvider, workspace), task.Permissions), task.Autonomy, RuntimeSettings.Current.AgentMemoryCeilingMb); + /// + /// Stamp a network-off run's broker port onto its spec, so a confining runner able to build one runs it in a + /// namespace SEALED to that broker instead of severing it from everything — the broker included, which left such a + /// run unable to reach any model. Only network-off runs: a run with network reaches its broker already, and its + /// egress is 's business. Returns the spec itself when there is nothing to stamp. + /// + internal static SandboxSpec ApplySealedEgress(SandboxSpec spec, AgentPermissions permissions, int? brokerPort) => + permissions.Network == AgentNetworkAccess.Off && brokerPort is { } port ? spec with { ModelBrokerPort = port } : spec; + + /// What the executor's hardening reads beyond the task itself: the model endpoint and the workspace the egress allowlist is built from, and the port of the run's brokered model lease, if it has one. + private readonly record struct SpecHardening(string? ModelBaseUrl, string? ModelProvider, WorkspaceProvisionRequest? Workspace, int? ModelBrokerPort); + + /// The harness invocation with every one of the executor's own spec post-processings applied — the egress posture, the broker seal, the write scope and the tier's resource ceilings. One name so the launch and each revise round cannot drift apart on which hardening they got. + private static SandboxSpec HardenSpec(SandboxSpec spec, AgentTask task, SpecHardening hardening) + { + var egress = ApplySealedEgress(ApplyEgressPolicy(spec, task.Permissions, hardening.ModelBaseUrl, hardening.ModelProvider, hardening.Workspace), task.Permissions, hardening.ModelBrokerPort); + + return ApplyResourceCeilings(ApplyWriteScope(egress, task.Permissions), task.Autonomy, RuntimeSettings.Current.AgentMemoryCeilingMb); + } /// The git clone URLs of every repo in the run's workspace provision (empty for a no-repo run) — the source of the allowlist's git hosts. private static IReadOnlyList CloneUrlsOf(WorkspaceProvisionRequest? workspace) => 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 50011e8be..d4b12a319 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Isolation/BubblewrapSandbox.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Isolation/BubblewrapSandbox.cs @@ -78,13 +78,17 @@ public static void EnsureSatisfiable(string? available, bool required) /// derivation turns into --unshare-net — so the record can /// neither claim a severance the argv did not request NOR miss one it did (an unenforceable allowlist fails /// closed to severed even while the launch asked to share the network). + /// + /// says the shared network IS a sealed namespace whose only destination is + /// the run's model broker: severed from everything else, so recorded as severed — and as sealed, so a reader knows + /// the one route it kept. /// - public static SandboxConfinement DeriveConfinement(string? available, string? unavailableReason, bool shareNetwork, IReadOnlyList? egressAllowlist) + public static SandboxConfinement DeriveConfinement(string? available, string? unavailableReason, bool shareNetwork, IReadOnlyList? egressAllowlist, bool sealedToBroker = false) { if (available is null) return new SandboxConfinement { Outcome = SandboxConfinementOutcome.Unconfined, Reason = unavailableReason ?? SandboxConfinement.ReasonNoBubblewrap }; - return new SandboxConfinement { Outcome = SandboxConfinementOutcome.Confined, NetworkSevered = EgressFor(shareNetwork, egressAllowlist).Mode != SandboxEgressMode.Full }; + return new SandboxConfinement { Outcome = SandboxConfinementOutcome.Confined, NetworkSevered = sealedToBroker || EgressFor(shareNetwork, egressAllowlist).Mode != SandboxEgressMode.Full, EgressSealedToBroker = sealedToBroker }; } /// diff --git a/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Isolation/CapabilityProbe.cs b/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Isolation/CapabilityProbe.cs new file mode 100644 index 000000000..957f53bba --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Isolation/CapabilityProbe.cs @@ -0,0 +1,60 @@ +namespace CodeSpace.Core.Services.Agents.Sandbox.Isolation; + +/// +/// Keeps the answer to a question this process can only settle by trying — are ip and nft runnable +/// (), can it build a sealed namespace (). +/// A proof holds for the process. A failure may be transient — a fork that failed once, a slow first mount of +/// /run/netns, rtnl held by a burst of teardowns — so it stands for one retry interval and is then probed +/// again, with its reason kept for whoever reports what could not be done. +/// +/// The FIRST probe is waited for by every caller: a fresh worker that may well seal must not refuse its first +/// launches while it finds out. A RE-probe is made by one caller outside the lock while the others take the standing +/// failure — which they would have got anyway — instead of queueing behind a probe that can take tens of seconds on a +/// host that keeps failing slowly. Timed on a monotonic clock, so a wall-clock step cannot hold a failure forever or +/// re-probe on every call. +/// +internal sealed class CapabilityProbe(Func probe, Func monotonicNow, TimeSpan retryInterval) +{ + private readonly object _lock = new(); + private bool _probed; + private bool _proven; + private bool _probing; + private TimeSpan _retryAt; + private string? _reason; + + public bool Holds + { + get + { + lock (_lock) + { + if (_proven) return true; + if (!_probed) return Record(probe()); + if (_probing || monotonicNow() < _retryAt) return false; + + _probing = true; + } + + var reason = probe(); + + lock (_lock) + { + _probing = false; + return Record(reason); + } + } + } + + /// Why the last probe failed, or null when none has failed since the last proof. + public string? UnavailableReason { get { lock (_lock) return _reason; } } + + private bool Record(string? reason) + { + _probed = true; + _reason = reason; + _proven = reason is null; + _retryAt = monotonicNow() + retryInterval; + + return _proven; + } +} diff --git a/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Isolation/EgressSubnetAllocator.cs b/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Isolation/EgressSubnetAllocator.cs index 9fc7d46de..bb4bc7861 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Isolation/EgressSubnetAllocator.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Isolation/EgressSubnetAllocator.cs @@ -97,9 +97,12 @@ public sealed record Lease private const int Octet3Count = 254; private const int Block4Count = 64; - /// How far the probe walks before failing closed. Bounds the worst-case syscall count of one acquire, and is far above any plausible per-host concurrency (the /30 space itself holds ≈4.1M). + /// How many candidates one acquire may find held by this worker or TRY to reserve before failing closed. Bounds the worst-case syscall count of one acquire, and is far above any plausible per-host concurrency (the /30 space itself holds ≈4.1M). Candidates the host already routes are passed over without counting against it. internal const int MaxConcurrentReservations = 4096; + /// Every /30 can name — the space a walk may pass through when the host routes the start of it. + internal const int CandidateCount = Octet2Count * Octet3Count * Block4Count; + private readonly object _lock = new(); private readonly Dictionary _byRun = new(StringComparer.Ordinal); private readonly HashSet _inUse = new(StringComparer.Ordinal); @@ -166,7 +169,7 @@ private sealed class DegradationScope : IDisposable /// concurrency. Nothing inside the loop decides it: a single unopenable file is somebody else's reservation until /// a fresh probe file says otherwise. /// - public Lease Acquire(string runId) + public Lease Acquire(string runId, HostRoutedPrefixes? hostRoutes = null) { lock (_lock) { @@ -174,11 +177,31 @@ public Lease Acquire(string runId) EnsureHostCanReserve(); - for (var index = 0; index < MaxConcurrentReservations; index++) + // Up to MaxConcurrentReservations candidates are held here or TRIED — that bound is about concurrency — + // while ranges the host already routes are passed over whole, without counting against it, so a broad + // route over the first range moves the walk on through 10.0.0.0/8 instead of refusing while unrouted /30s + // remain, and costs one step rather than one per /30 it covers. + var heldHere = 0; + var tried = 0; + var routed = 0; + + for (var index = 0; index < CandidateCount && heldHere + tried < MaxConcurrentReservations; index++) { var cidr = CidrAt(index); - if (_inUse.Contains(cidr)) continue; + if (_inUse.Contains(cidr)) { heldHere++; continue; } + + // The lock says no live WORKER holds it; the host's routes say nothing ELSE does — its own network, or a + // run whose namespace outlived the worker that reserved it. Neither alone is enough. + if (hostRoutes?.OverlapEnd(cidr) is { } routedEnd) + { + var past = Math.Max(IndexAtOrAbove((ulong)routedEnd + 1), index + 1); + routed += past - index; + index = past - 1; + continue; + } + + tried++; if (!TryReserve(cidr, runId, out var handle)) continue; @@ -188,6 +211,11 @@ public Lease Acquire(string runId) return LeaseFor(cidr); } + // Only when nothing could even be TRIED is it the routes; otherwise the directory or real concurrency is + // the wall, and ExhaustionOrRefusal re-probes to say which. + if (tried == 0 && routed > 0) + throw new InvalidOperationException($"EgressSubnetAllocator: no /30 in 10.0.0.0/8 is free that this host does not already route — this worker holds {_inUse.Count}, and every other candidate overlaps a route or address the host holds (its own network, or a namespace that outlived its worker)."); + throw ExhaustionOrRefusal(); } } @@ -431,7 +459,7 @@ private static void StampOwner(FileStream handle, string runId) /// Where reserves: a fixed leaf under the agent-run spool root, resolved through the SAME DurableRoots the spool itself uses, so every worker process sharing that root resolves the same directory and an operator who relocates the spool relocates the reservations. Resolved per acquire rather than at type init, so it reads the settings the deployment bound rather than whatever was current when this class was first touched. internal string ReservationDirectory => _directory ?? Path.Combine(DurableRoots.AgentRunSpool(RuntimeSettings.Current.AgentRunSpoolDirectory), ReservationLeaf); - private static string CidrAt(int index) + internal static string CidrAt(int index) { var block4 = index % Block4Count; // 0..63 → octet4 = block4*4 var octet3 = index / Block4Count % Octet3Count; // 0..253 → +1 @@ -439,6 +467,24 @@ private static string CidrAt(int index) return $"10.{octet2 + 1}.{octet3 + 1}.{block4 * 4}/30"; } + /// The first candidate index whose /30 starts at or above — the inverse of , which walks 10.1.1.0 up to 10.254.254.252 skipping the .0 and .255 second and third octets. when none does. + internal static int IndexAtOrAbove(ulong address) + { + if (address > 0x0AFEFEFC) return CandidateCount; // past 10.254.254.252 + if (address < 0x0A010100) return 0; // before 10.1.1.0 + + var octet2 = (int)(address >> 16 & 0xFF); + var octet3 = (int)(address >> 8 & 0xFF); + var block4 = (int)((address & 0xFF) + 3) / 4; + + if (octet3 == 0) (octet3, block4) = (1, 0); + if (octet3 == 255) (octet2, octet3, block4) = (octet2 + 1, 1, 0); + if (block4 == Block4Count) (octet3, block4) = (octet3 + 1, 0); + if (octet3 == 255) (octet2, octet3) = (octet2 + 1, 1); + + return octet2 > Octet2Count ? CandidateCount : ((octet2 - 1) * Octet3Count + (octet3 - 1)) * Block4Count + block4; + } + private static Lease LeaseFor(string cidr) { var slash = cidr.IndexOf('/'); diff --git a/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Isolation/FilteredEgressNetns.cs b/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Isolation/FilteredEgressNetns.cs index 44a7f0c5d..b9a173e56 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Isolation/FilteredEgressNetns.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Isolation/FilteredEgressNetns.cs @@ -6,16 +6,37 @@ namespace CodeSpace.Core.Services.Agents.Sandbox.Isolation; /// /// The privileged executor of a (B3.2 enforcement) — sets up a per-run filtered /// network namespace, runs a command INSIDE it (so its only egress is the nftables allowlist), and tears the -/// namespace down. Needs ip + nft + CAP_NET_ADMIN/root, so it runs for real only in the +/// namespace down. A network-off run whose model is brokered gets the SEALED variant instead +/// (), whose only reachable destination is that broker. Needs ip + nft + CAP_NET_ADMIN/root, so it runs for real only in the /// privileged sandbox-isolation CI job; gates it everywhere else. Teardown is BEST-EFFORT /// and ALWAYS runs (even on a setup failure mid-way), so a failed run never leaks a netns / veth / nft table. /// public static class FilteredEgressNetns { - private static readonly Lazy _supported = new(ProbeSupported); - /// True when ip + nft are present (the binaries the plan drives). Actual privilege to create a netns is exercised at run time — a setup failure fails closed. - public static bool IsSupported => _supported.Value; + /// How long a failed tools or seal probe stands before it is tried again (see ): caching a transient failure for the process lifetime would sever every later network-off brokered run on this worker from its model. + internal static readonly TimeSpan SealProbeRetryInterval = TimeSpan.FromMinutes(1); + + private static readonly long ProcessStart = System.Diagnostics.Stopwatch.GetTimestamp(); + + private static readonly CapabilityProbe Tools = new(() => ProbeSupported() ? null : "ip or nft is not installed", () => System.Diagnostics.Stopwatch.GetElapsedTime(ProcessStart), SealProbeRetryInterval); + + private static readonly CapabilityProbe Seal = new(ProbeSeal, () => System.Diagnostics.Stopwatch.GetElapsedTime(ProcessStart), SealProbeRetryInterval); + + /// True when ip + nft are present (the binaries the plan drives). Actual privilege to create a netns is exercised at run time — a setup failure fails closed. A failed probe is retried like the seal probe (): a fork that failed once at boot must not disable the broker's wide bind and every seal for the process lifetime. + public static bool IsSupported => Tools.Holds; + + /// + /// True when this process has PROVED it can build a namespace: the binaries are present AND one throwaway + /// namespace was created and deleted, and nftables answered. The binaries alone are not enough — an image can ship + /// them to a worker that runs without the privilege to use them — and a network-off run is sealed only where this + /// holds, severed everywhere else. A proof is kept for the process; a failure is kept for + /// and then probed again, and says why in . + /// + public static bool CanSeal => Seal.Holds; + + /// Why the last seal probe failed — the failed step and its output — or null when none has failed since the last proof. Read by whatever reports a run that could not be sealed, so the cause is not lost with the probe. + public static string? SealUnavailableReason => Seal.UnavailableReason; /// The outcome of running a command inside the filtered netns: the command's exit code + its combined output, plus whether the netns setup itself succeeded. public sealed record Outcome @@ -59,8 +80,56 @@ public static async Task SetupAsync(string runId, IReadOnlyList + /// Set up a SEALED netns for a network-off run whose model is brokered (): + /// its only reachable destination is on the returned . + /// The same fail-closed contract as , the same /30 reservation, and the same + /// — the names are the run id's either way. + /// + public static async Task SetupSealedAsync(string runId, int brokerPort, int timeoutSeconds, CancellationToken cancellationToken) + { + if (await ReadHostRoutesAsync(timeoutSeconds, cancellationToken).ConfigureAwait(false) is not { } routes) return RoutesUnreadable; + var (subnet, exhausted) = Reserve(EgressSubnetAllocator.Host, runId, routes); + if (subnet is null) return new SetupResult { SetupOk = false, SetupError = exhausted }; + + return await ApplyAsync(runId, FilteredEgressPlan.BuildSealed(runId, brokerPort, subnet), timeoutSeconds, cancellationToken).ConfigureAwait(false); + } + + /// + /// Reserve the run's /30, or say why the host has none free — every candidate routed or held. That outcome is a + /// failed SETUP, reported like any other step's, so a caller that types its setup failures (a sealed run's refusal) + /// types this one too. A host whose reservation directory is unusable still throws its own typed refusal. + /// + internal static (EgressSubnetAllocator.Lease? Subnet, string? Exhausted) Reserve(EgressSubnetAllocator allocator, string runId, HostRoutedPrefixes routes) + { + try { return (allocator.Acquire(runId, routes), null); } + catch (InvalidOperationException exhausted) { return (null, exhausted.Message); } + } + + /// What the host already routes, so no /30 it uses is handed out (); null when it cannot be read, which fails the setup closed rather than reserving blind. + private static async Task ReadHostRoutesAsync(int timeoutSeconds, CancellationToken cancellationToken) + { + try + { + var (exit, output) = await RunHostAsync(HostRoutedPrefixes.ListArgv, stdin: null, timeoutSeconds, cancellationToken, stdoutOnly: true).ConfigureAwait(false); + + return exit == 0 ? HostRoutedPrefixes.Parse(output) : null; + } + catch (Exception exception) when (exception is not OperationCanceledException) { return null; } + } + + private static readonly SetupResult RoutesUnreadable = new() { SetupOk = false, SetupError = $"{string.Join(' ', HostRoutedPrefixes.ListArgv)} could not be read, so no /30 could be chosen that this host does not already route" }; + + /// Run a plan's setup, its route check and its ruleset, tearing down whatever was created the moment any step fails or throws. Internal so a real-kernel test can apply a plan on a /30 it owns. + internal static async Task ApplyAsync(string runId, FilteredEgressPlan plan, int timeoutSeconds, CancellationToken cancellationToken) + { try { foreach (var argv in plan.SetupCommands) @@ -73,6 +142,13 @@ public static async Task SetupAsync(string runId, IReadOnlyList RunAsync(string runId, IReadOnlyList a } } + /// + /// Build and delete one throwaway namespace and ask nftables to answer: null when both work, else the step that did + /// not. The name is this PROCESS's own, deleted first and always — whether or not the add reported success, since + /// an add killed at its timeout may still have created it — so a probe can leave at most one namespace behind per + /// worker, and the next probe from the same process removes it. + /// + private static string? ProbeSeal() + { + if (!IsSupported) return "ip or nft is not installed"; + + var probe = $"cs-seal-probe-{Environment.ProcessId}"; + + try + { + RunHostAsync(new[] { "ip", "netns", "del", probe }, null, 10, CancellationToken.None).GetAwaiter().GetResult(); + + var (addExit, addOutput) = RunHostAsync(new[] { "ip", "netns", "add", probe }, null, 10, CancellationToken.None).GetAwaiter().GetResult(); + if (addExit != 0) return $"ip netns add → exit {addExit}: {Trim(addOutput)}"; + + var (nftExit, nftOutput) = RunHostAsync(new[] { "nft", "list", "tables" }, null, 10, CancellationToken.None).GetAwaiter().GetResult(); + return nftExit == 0 ? null : $"nft list tables → exit {nftExit}: {Trim(nftOutput)}"; + } + catch (Exception exception) { return $"the probe threw: {exception.Message}"; } + finally + { + try { RunHostAsync(new[] { "ip", "netns", "del", probe }, null, 10, CancellationToken.None).GetAwaiter().GetResult(); } catch { /* best-effort: the next probe deletes it first */ } + } + } + private static bool ProbeSupported() { try @@ -143,7 +248,7 @@ private static bool ProbeSupported() catch { return false; } } - private static async Task<(int Exit, string Output)> RunHostAsync(IReadOnlyList argv, string? stdin, int timeoutSeconds, CancellationToken cancellationToken) + private static async Task<(int Exit, string Output)> RunHostAsync(IReadOnlyList argv, string? stdin, int timeoutSeconds, CancellationToken cancellationToken, bool stdoutOnly = false) { var psi = new ProcessStartInfo { @@ -158,7 +263,7 @@ private static bool ProbeSupported() using var process = new Process { StartInfo = psi }; var output = new StringBuilder(); process.OutputDataReceived += (_, e) => { if (e.Data is not null) lock (output) output.AppendLine(e.Data); }; - process.ErrorDataReceived += (_, e) => { if (e.Data is not null) lock (output) output.AppendLine(e.Data); }; + process.ErrorDataReceived += (_, e) => { if (e.Data is not null && !stdoutOnly) lock (output) output.AppendLine(e.Data); }; process.Start(); process.BeginOutputReadLine(); diff --git a/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Isolation/FilteredEgressPlan.cs b/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Isolation/FilteredEgressPlan.cs index d6abaa6b2..9930a5b76 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Isolation/FilteredEgressPlan.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Isolation/FilteredEgressPlan.cs @@ -28,11 +28,45 @@ public sealed record FilteredEgressPlan public required string HostAddrCidr { get; init; } public required string NsAddrCidr { get; init; } public required string HostIp { get; init; } + public required string NsIp { get; init; } public required string NsSubnetCidr { get; init; } /// The argv sequences (each an executable + args) that build the filtered netns, in order — run BEFORE is applied. public required IReadOnlyList> SetupCommands { get; init; } + /// + /// The argv that asks the kernel how the host reaches the namespace's end — the lookup every reply the host itself + /// sends there makes, the broker's included. Run after : the /30 was chosen from the + /// routes the host lists (), but only the kernel's own lookup accounts for a policy + /// rule, or the null route in a table one consults before main, that would discard those replies after a + /// clean setup. It is an output lookup, so it does not see an allowlist run's NAT'd replies, which are routed on + /// input: a rule keyed on the uplink (iif) can still divert those. + /// + public IReadOnlyList RouteCheckArgv => new[] { "ip", "route", "get", NsIp, "from", HostIp }; + + /// Why 's answer does not take the namespace's traffic through its own host veth, or null when it does. + internal string? RouteCheckFailure(int exit, string output) + { + var asked = string.Join(' ', RouteCheckArgv); + + if (exit != 0) return $"{asked} → exit {exit}: {output.Trim()} — this host cannot route replies back to {NsSubnetCidr} (a null route or a policy rule discards them), so the run could never be answered"; + + return RoutedDevice(output) == VethHost ? null : $"{asked} → {output.Trim()} — this host routes {NsIp} through something other than the run's own veth {VethHost}, so the run could never be answered"; + } + + /// + /// The device ip route get answered with — the word after its one dev — or null for anything else. + /// Read from the text answer, not -j: route get learned JSON only in iproute2 5.0, three releases + /// after the route listing the allocator reads, and on those releases it prints text under -j too. + /// + private static string? RoutedDevice(string output) + { + var words = output.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + var dev = Array.IndexOf(words, "dev"); + + return dev >= 0 && dev == Array.LastIndexOf(words, "dev") && dev + 1 < words.Length ? words[dev + 1] : null; + } + /// The nftables ruleset (NAT masquerade + the scoped default-drop forward allowlist) applied via nft -f - on STDIN after . Kept off the argv (multi-line) so it pipes cleanly. public required string NftRuleset { get; init; } @@ -66,19 +100,9 @@ public static FilteredEgressPlan Build(string runId, IReadOnlyList allow var nftRuleset = BuildNftRuleset(table, subnetCidr, allowedIps); - var setup = new List> - { - new[] { "ip", "netns", "add", ns }, - new[] { "ip", "link", "add", vethHost, "type", "veth", "peer", "name", vethNs }, - new[] { "ip", "link", "set", vethNs, "netns", ns }, - new[] { "ip", "addr", "add", $"{hostIp}/30", "dev", vethHost }, - new[] { "ip", "link", "set", vethHost, "up" }, - new[] { "ip", "netns", "exec", ns, "ip", "addr", "add", $"{nsIp}/30", "dev", vethNs }, - new[] { "ip", "netns", "exec", ns, "ip", "link", "set", vethNs, "up" }, - new[] { "ip", "netns", "exec", ns, "ip", "link", "set", "lo", "up" }, - new[] { "ip", "netns", "exec", ns, "ip", "route", "add", "default", "via", hostIp }, - new[] { "sysctl", "-w", "net.ipv4.ip_forward=1" }, - }; + var setup = NamespaceSetup(ns, vethHost, vethNs, subnet); + setup.Add(new[] { "ip", "netns", "exec", ns, "ip", "route", "add", "default", "via", hostIp }); + setup.Add(new[] { "sysctl", "-w", "net.ipv4.ip_forward=1" }); return new FilteredEgressPlan { @@ -88,6 +112,7 @@ public static FilteredEgressPlan Build(string runId, IReadOnlyList allow HostAddrCidr = $"{hostIp}/30", NsAddrCidr = $"{nsIp}/30", HostIp = hostIp, + NsIp = nsIp, NsSubnetCidr = subnetCidr, SetupCommands = setup, NftRuleset = nftRuleset, @@ -96,6 +121,52 @@ public static FilteredEgressPlan Build(string runId, IReadOnlyList allow }; } + /// + /// Build the plan for a network-off run whose model is reached through its broker: the same per-run namespace and + /// /30, but SEALED — no default route (a packet to anywhere but the /30 fails with ENETUNREACH at once), no + /// forwarding, no NAT and no DNS, and an input filter on the host veth that admits exactly one destination, the + /// broker's on the gateway. Everything else the worker listens on — its own API, + /// every other run's broker port — is dropped, which the allowlist plan, with no input filter at all, leaves + /// reachable through the gateway. The table is inet so the veth's IPv6 link-local address is covered too. + /// Teardown is the same , reconstructed from the run id alone. + /// + public static FilteredEgressPlan BuildSealed(string runId, int brokerPort, EgressSubnetAllocator.Lease subnet) + { + var ns = NamespaceFor(runId); + var slug = Slug(runId); + var vethHost = $"csh-{slug}"; + var vethNs = $"csn-{slug}"; + + return new FilteredEgressPlan + { + Namespace = ns, + VethHost = vethHost, + VethNs = vethNs, + HostAddrCidr = $"{subnet.HostIp}/30", + NsAddrCidr = $"{subnet.NsIp}/30", + HostIp = subnet.HostIp, + NsIp = subnet.NsIp, + NsSubnetCidr = subnet.Cidr, + SetupCommands = NamespaceSetup(ns, vethHost, vethNs, subnet), + NftRuleset = BuildSealedNftRuleset(ns, vethHost, subnet.HostIp, brokerPort), + ExecPrefix = new[] { "ip", "netns", "exec", ns }, + TeardownCommands = TeardownCommandsFor(runId), + }; + } + + /// The namespace, its veth pair and the /30 on both ends, with loopback up — what both plans share. Routing and forwarding are each plan's own. + private static List> NamespaceSetup(string ns, string vethHost, string vethNs, EgressSubnetAllocator.Lease subnet) => new() + { + new[] { "ip", "netns", "add", ns }, + new[] { "ip", "link", "add", vethHost, "type", "veth", "peer", "name", vethNs }, + new[] { "ip", "link", "set", vethNs, "netns", ns }, + new[] { "ip", "addr", "add", $"{subnet.HostIp}/30", "dev", vethHost }, + new[] { "ip", "link", "set", vethHost, "up" }, + new[] { "ip", "netns", "exec", ns, "ip", "addr", "add", $"{subnet.NsIp}/30", "dev", vethNs }, + new[] { "ip", "netns", "exec", ns, "ip", "link", "set", vethNs, "up" }, + new[] { "ip", "netns", "exec", ns, "ip", "link", "set", "lo", "up" }, + }; + /// The per-run netns / nft-table name — derived PURELY from , so a reaper / teardown reconstructs it with no setup-time state. public static string NamespaceFor(string runId) => $"cs-egr-{Slug(runId)}"; @@ -114,6 +185,7 @@ public static IReadOnlyList> TeardownCommandsFor(string ru new[] { "ip", "netns", "del", ns }, // removes the ns + its veth end new[] { "ip", "link", "del", vethHost }, // best-effort: del may already be gone with the ns new[] { "nft", "delete", "table", "ip", ns }, + new[] { "nft", "delete", "table", "inet", ns }, // the sealed plan's table; best-effort, absent for an allowlist run }; } @@ -146,6 +218,35 @@ internal static string BuildNftRuleset(string table, string subnet, IReadOnlyLis return string.Join("\n", lines) + "\n"; } + /// + /// The sealed ruleset fed to nft -f -: on the INPUT hook, traffic arriving from this run's host veth may + /// reach only : over TCP (plus the replies to it); on the + /// FORWARD hook it is dropped outright. Keyed on the veth, never on the subnet, so another run's namespace — even + /// one handed the same /30 by a degraded allocator — is untouched by this table. + /// + /// It REPLACES any table of the same name rather than adding to it: every revise round of a run gets the same + /// names, so a round whose teardown failed to delete its table would otherwise have the next round's rules appended + /// after its own drop, cutting that round off from its broker. Declared, deleted and redefined in one nft -f + /// transaction, which the kernel applies atomically. + /// + internal static string BuildSealedNftRuleset(string table, string vethHost, string hostIp, int brokerPort) => string.Join("\n", new[] + { + $"table inet {table} {{}}", + $"delete table inet {table}", + $"table inet {table} {{", + " chain input {", + " type filter hook input priority 0;", + $" iifname \"{vethHost}\" ct state established,related accept", + $" iifname \"{vethHost}\" ip daddr {hostIp} tcp dport {brokerPort} accept", + $" iifname \"{vethHost}\" drop", + " }", + " chain forward {", + " type filter hook forward priority 0;", + $" iifname \"{vethHost}\" drop", + " }", + "}", + }) + "\n"; + private static string Slug(string runId) { var clean = new string((runId ?? "").Where(char.IsLetterOrDigit).ToArray()).ToLowerInvariant(); diff --git a/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Isolation/HostRoutedPrefixes.cs b/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Isolation/HostRoutedPrefixes.cs new file mode 100644 index 000000000..4829068f7 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Isolation/HostRoutedPrefixes.cs @@ -0,0 +1,86 @@ +using System.Net; +using System.Net.Sockets; +using System.Text.Json; + +namespace CodeSpace.Core.Services.Agents.Sandbox.Isolation; + +/// +/// The IPv4 prefixes this host already routes — every entry of every routing table (ip -j -4 route show table +/// all) but the default route — read so a per-run /30 is never one the host already uses. A /30 is put on a HOST +/// veth, which makes its gateway a local address and its prefix a connected route more specific than anything it +/// overlaps: a /30 inside the worker's own LAN or pod network would shadow real peers, and one still held by a run +/// that outlived its worker (its namespace and veth survive a restart; the reservation lock does not) would split the +/// replies of two runs between two veths. The kernel's own table is the only record that sees both. +/// +public sealed class HostRoutedPrefixes +{ + private readonly IReadOnlyList<(uint Network, uint Mask)> _prefixes; + + private HostRoutedPrefixes(IReadOnlyList<(uint Network, uint Mask)> prefixes) => _prefixes = prefixes; + + /// The argv that lists them, as JSON. + public static IReadOnlyList ListArgv { get; } = new[] { "ip", "-j", "-4", "route", "show", "table", "all" }; + + /// Parse 's output. A destination with no prefix length is a single address (/32). The default route overlaps everything and is not a use of any one /30, so it is left out — and so is anything broader than a /8, which is a default in all but name (a VPN's 0.0.0.0/1 + 128.0.0.0/1 pair), not a network with peers in it. + public static HostRoutedPrefixes Parse(string ipJson) + { + using var document = JsonDocument.Parse(ipJson); + var prefixes = new List<(uint, uint)>(); + + foreach (var route in document.RootElement.EnumerateArray()) + if (route.TryGetProperty("dst", out var dst) && dst.GetString() is { } destination && destination != "default" && TryParsePrefix(destination, out var prefix) && prefix.Mask >= NarrowestDefaultLike && !(IsNullRoute(route) && prefix.Mask < SlashThirty)) + prefixes.Add(prefix); + + return new HostRoutedPrefixes(prefixes); + } + + /// Whether shares any address with a prefix this host routes. + public bool Overlaps(string cidr) => OverlapEnd(cidr) is not null; + + /// The last address of the widest routed prefix overlaps, or null when it overlaps none — so a walk can move past the whole routed range at once instead of one /30 at a time. + public uint? OverlapEnd(string cidr) + { + if (!TryParsePrefix(cidr, out var candidate)) throw new ArgumentException($"'{cidr}' is not an IPv4 prefix.", nameof(cidr)); + + uint? end = null; + + foreach (var routed in _prefixes) + if ((routed.Network & (routed.Mask & candidate.Mask)) == (candidate.Network & (routed.Mask & candidate.Mask))) + end = Math.Max(end ?? 0, routed.Network | ~routed.Mask); + + return end; + } + + /// + /// A route that discards what it matches (blackhole, unreachable, prohibit, throw). One + /// BROADER than a /30 has no peers to shadow and, in the table that holds a run's connected /30, loses longest-prefix + /// match to it, so a hardened host's RFC 1918 null route must not refuse every launch. One as narrow as a /30 or + /// narrower — a banned /32 — WINS that match and would discard the run's replies, so it still occupies what it + /// covers. A table a policy rule consults BEFORE that one is decided by rule order, not prefix length, which this + /// list cannot see; the setup asks the kernel instead (). + /// + private static bool IsNullRoute(JsonElement route) => + route.TryGetProperty("type", out var type) && type.GetString() is "blackhole" or "unreachable" or "prohibit" or "throw"; + + /// The mask of a /30 — the prefix a run's veth route has. + private const uint SlashThirty = 0xFFFFFFFC; + + /// The mask of a /8: a route broader than this carries every address as a default route would. + private const uint NarrowestDefaultLike = 0xFF000000; + + private static bool TryParsePrefix(string text, out (uint Network, uint Mask) prefix) + { + prefix = default; + var slash = text.IndexOf('/'); + var length = 32; + + if (slash >= 0 && !int.TryParse(text[(slash + 1)..], out length)) return false; + if (length is < 0 or > 32 || !IPAddress.TryParse(slash >= 0 ? text[..slash] : text, out var address) || address.AddressFamily != AddressFamily.InterNetwork) return false; + + var bytes = address.GetAddressBytes(); + var mask = length == 0 ? 0u : uint.MaxValue << (32 - length); + + prefix = ((uint)(bytes[0] << 24 | bytes[1] << 16 | bytes[2] << 8 | bytes[3]) & mask, mask); + return true; + } +} diff --git a/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Isolation/SandboxEgressPolicy.cs b/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Isolation/SandboxEgressPolicy.cs index 60b65d19d..6a06964f4 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Isolation/SandboxEgressPolicy.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Isolation/SandboxEgressPolicy.cs @@ -11,6 +11,9 @@ public enum SandboxEgressMode /// Egress filtered to only — the deny-by-default allowlist. The host filtering (privileged netns + nftables / proxy) is enforced by a later sandbox slice. Filtered, + + /// No egress, except the one TCP port on the namespace's gateway — the run's own model-credential broker. A network-off run whose model is brokered, on a host able to build the sealed namespace; everywhere else that run is . + Sealed, } /// @@ -18,7 +21,9 @@ public enum SandboxEgressMode /// 底座. It turns the binary AllowNetwork + an optional host allowlist into a single explicit policy, and is /// FAIL-CLOSED by construction: an allowlist requested on a runner that cannot ENFORCE filtering degrades to /// (severed), NEVER to — so a narrowing -/// can never silently widen to "any host". No I/O — the actual host filtering is a later slice that reads this. +/// can never silently widen to "any host". A network-off run is never widened either: sealing it to its broker +/// keeps it off everything else, and a run that cannot be sealed stays severed. No I/O — the actual host filtering +/// is a later slice that reads this. /// public sealed record SandboxEgressPolicy { @@ -27,6 +32,9 @@ public sealed record SandboxEgressPolicy /// The hosts reachable under — normalized (trimmed, lower-cased, de-duped, blanks dropped). Empty for None / Full. public IReadOnlyList AllowedHosts { get; init; } = Array.Empty(); + /// The one gateway port reachable under ; null for every other mode. + public int? BrokerPort { get; init; } + /// No egress. public static SandboxEgressPolicy Denied { get; } = new() { Mode = SandboxEgressMode.None }; @@ -34,12 +42,13 @@ public sealed record SandboxEgressPolicy public static SandboxEgressPolicy Shared { get; } = new() { Mode = SandboxEgressMode.Full }; /// - /// Derive the egress policy: no network ⇒ None; network without an allowlist ⇒ Full (today's behaviour); + /// Derive the egress policy: no network ⇒ None, or Sealed to when the caller + /// has one (a brokered run on a host that can seal); network without an allowlist ⇒ Full (today's behaviour); /// network WITH an allowlist ⇒ Filtered when the runner can enforce it, else None (FAIL-CLOSED — never Full). /// - public static SandboxEgressPolicy Derive(bool allowNetwork, IReadOnlyList? allowlist, bool canEnforceAllowlist) + public static SandboxEgressPolicy Derive(bool allowNetwork, IReadOnlyList? allowlist, bool canEnforceAllowlist, int? sealableBrokerPort = null) { - if (!allowNetwork) return Denied; + if (!allowNetwork) return sealableBrokerPort is { } port ? new SandboxEgressPolicy { Mode = SandboxEgressMode.Sealed, BrokerPort = port } : Denied; var hosts = NormalizeHosts(allowlist); diff --git a/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Runners/LocalProcessRunner.Commands.cs b/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Runners/LocalProcessRunner.Commands.cs index 89bbbfad8..3784e94cb 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Runners/LocalProcessRunner.Commands.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Runners/LocalProcessRunner.Commands.cs @@ -30,6 +30,7 @@ private async Task PrepareCommandAsync(SandboxSpec spec, Canc // command below. A no-op for every run that does not mention the token: the values are identical. var launched = ResolveModelBrokerHost(spec, egress.GatewayIp); foreach (var (name, value) in launched.Environment) invocation.StartInfo.Environment[name] = value; + WithoutProxiesWhenSealed(invocation.StartInfo.Environment, spec, egress.ExecPrefix); if (spec.ConfigHomeEnvVars.Count > 0) { diff --git a/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Runners/LocalProcessRunner.Durable.cs b/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Runners/LocalProcessRunner.Durable.cs index a7df02bc0..b292ac753 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Runners/LocalProcessRunner.Durable.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Runners/LocalProcessRunner.Durable.cs @@ -314,14 +314,17 @@ private static bool TryFileLength(string path, out long length) } /// - /// Derive this run's egress posture and, when it is an enforceable Filtered allowlist, set up the per-run netns and - /// return the ip netns exec prefix the supervisor chain runs behind plus the teardown key. None/Full need no - /// netns (empty prefix, null key). Fail-closed: an allowlist requested on a runner that cannot enforce it degrades - /// to None (no netns) via , and a netns whose setup fails throws. + /// Derive this run's egress posture and, when it is an enforceable Filtered allowlist or a Sealed broker route, set + /// up the per-run netns and return the ip netns exec prefix the supervisor chain runs behind plus the + /// teardown key. None/Full need no netns (empty prefix, null key). Fail-closed: an allowlist requested on a runner + /// that cannot enforce it degrades to None (no netns) via , a network-off run this + /// host cannot seal stays severed, and a netns whose setup fails throws. /// private static async Task<(IReadOnlyList ExecPrefix, string? Key, string? GatewayIp)> SetupEgressNetnsAsync(SandboxSpec spec, string spoolKey, CancellationToken ct) { - var policy = SandboxEgressPolicy.Derive(spec.AllowNetwork, spec.EgressAllowlist, FilteredEgressNetns.IsSupported); + var policy = SandboxEgressPolicy.Derive(spec.AllowNetwork, spec.EgressAllowlist, FilteredEgressNetns.IsSupported, SealableBrokerPort(spec)); + + if (policy.Mode == SandboxEgressMode.Sealed) return await SetupSealedNetnsAsync(policy.BrokerPort!.Value, spoolKey, ct).ConfigureAwait(false); if (policy.Mode != SandboxEgressMode.Filtered) return (Array.Empty(), null, null); @@ -339,6 +342,24 @@ private static bool TryFileLength(string path, out long length) return (setup.ExecPrefix, spoolKey, setup.HostIp); } + /// + /// The broker port a network-off run may be sealed to — only where bubblewrap confines the command (exactly where + /// it would otherwise sever it with --unshare-net) and this host has proved it can build a namespace. Null + /// everywhere else, so an unconfined host keeps the launch it always had and a host that cannot seal keeps severing. + /// + private static int? SealableBrokerPort(SandboxSpec spec) => + spec.ModelBrokerPort is { } port && BubblewrapSandbox.Available is not null && FilteredEgressNetns.CanSeal ? port : null; + + private static async Task<(IReadOnlyList ExecPrefix, string? Key, string? GatewayIp)> SetupSealedNetnsAsync(int brokerPort, string spoolKey, CancellationToken ct) + { + var setup = await FilteredEgressNetns.SetupSealedAsync(spoolKey, brokerPort, EgressSetupTimeoutSeconds, ct).ConfigureAwait(false); + + if (!setup.SetupOk) + throw new InvalidOperationException($"Sealed-egress netns setup failed (fail-closed — run aborted rather than launched with a network it was not given): {setup.SetupError}"); + + return (setup.ExecPrefix, spoolKey, setup.HostIp); + } + /// /// Create this run's cgroup-v2 resource-cap leaf (B4) when a memory/cpu cap is requested AND the operator delegated /// a root () on a cgroup-v2 host — returning the self-add prefix the @@ -877,6 +898,7 @@ internal static ProcessStartInfo BuildDurableStartInfo(SandboxSpec spec, string AppendChildCommand(info.ArgumentList, new CommandIsolationContext(spec, configHome, mcpDeclarationPath, egressExecPrefix ?? Array.Empty(), cgroupExecPrefix ?? Array.Empty())); ApplyEnvironment(info, spec); + WithoutProxiesWhenSealed(info.Environment, spec, egressExecPrefix ?? Array.Empty()); // Added AFTER ApplyEnvironment so they survive the scrub-driven Clear(); they are spool paths, not secrets. info.Environment["CSP_OUT"] = Path.Combine(spoolDir, StdoutFile); @@ -915,8 +937,10 @@ internal static ProcessStartInfo BuildDurableStartInfo(SandboxSpec spec, string /// Returns the spec UNCHANGED when nothing mentions the token, which is every run whose credential was /// not brokered — byte-identical command, argv and env, and no allocation. /// - /// A network-severed run (no netns, no shared network) resolves to loopback and cannot reach the broker — - /// nor could it reach the provider directly, so brokerage neither adds nor removes anything for it. + /// A network-off brokered run on a host that seals runs inside a namespace SEALED to its broker, and resolves + /// to that namespace's gateway like any other netns run. One that is severed instead (no netns, no shared network — + /// a host that cannot seal) resolves to loopback and cannot reach the broker — nor could it reach the provider + /// directly, so brokerage neither adds nor removes anything for it. /// /// scans only , /// and — a harness that instead wrote the @@ -928,15 +952,52 @@ internal static SandboxSpec ResolveModelBrokerHost(SandboxSpec spec, string? gat if (!MentionsModelBrokerHost(spec)) return spec; var host = gatewayIp is { Length: > 0 } reachable ? reachable : "127.0.0.1"; + var environment = spec.Environment.ToDictionary(entry => entry.Key, entry => WithModelBrokerHost(entry.Value, host), StringComparer.Ordinal); + + ExemptBrokerFromProxies(environment, host); return spec with { Command = WithModelBrokerHost(spec.Command, host), Args = spec.Args.Select(arg => WithModelBrokerHost(arg, host)).ToList(), - Environment = spec.Environment.ToDictionary(entry => entry.Key, entry => WithModelBrokerHost(entry.Value, host), StringComparer.Ordinal), + Environment = environment, }; } + private static readonly string[] NoProxyVariables = { "NO_PROXY", "no_proxy" }; + + /// + /// Keep a child that was handed a proxy from sending its model calls there. The broker is reached at an address no + /// operator's NO_PROXY can name ahead of time — a per-run gateway, or a loopback a NO_PROXY may simply omit. Both + /// spellings get the union of what the task or the worker set, because readers prefer different ones (curl, Python + /// and Claude Code lowercase; reqwest and Go uppercase) and a spelling created with the broker alone would hide the + /// operator's own exemptions from half of them. A child with no proxy is left as it was: a NO_PROXY written there + /// exempts nothing, and turns off Python's lookup of the OS proxy on a macOS or Windows worker. + /// + private static void ExemptBrokerFromProxies(Dictionary environment, string host) + { + if (!ProxyVariables.Any(name => ChildValue(environment, name) is { Length: > 0 })) return; + + var exempt = BrokerExempt(NoProxyVariables.Select(name => ChildValue(environment, name)), host); + + environment["NO_PROXY"] = exempt; + environment["no_proxy"] = LowercaseExempt(exempt); + } + + /// What the child sees for : the task's own value, else the worker's — but only a worker variable the scrub keeps (). The worker's ALL_PROXY never reaches the child, so it must not count as a proxy the child was handed. + private static string? ChildValue(IReadOnlyDictionary environment, string name) => environment.TryGetValue(name, out var own) ? own : EnvAllowlist.Contains(name) ? Environment.GetEnvironmentVariable(name) : null; + + /// The exemption list: every entry either spelling already carried, plus the broker's host — kept beside a *, which the uppercase readers honour as an entry but never match an IP address against. + internal static string BrokerExempt(IEnumerable spellings, string host) + { + var entries = spellings.SelectMany(value => (value ?? "").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + + return string.Join(",", entries.Contains(host, StringComparer.OrdinalIgnoreCase) ? entries : entries.Append(host)); + } + + /// What no_proxy gets: a lone * when the list holds one, because its lowercase-first readers honour "bypass everything" only as the WHOLE value — which already covers the broker — and would proxy every other host once an entry joined it. + internal static string LowercaseExempt(string exempt) => exempt.Split(',').Contains("*") ? "*" : exempt; + /// Whether any carrier in this spec still holds the broker-host token — the one read that decides whether a launch pays for the substitution at all. private static bool MentionsModelBrokerHost(SandboxSpec spec) => MentionsModelBrokerHost(spec.Command) || spec.Args.Any(MentionsModelBrokerHost) || spec.Environment.Values.Any(MentionsModelBrokerHost); @@ -953,6 +1014,25 @@ private static bool MentionsModelBrokerHost(SandboxSpec spec) => /// private static bool ShareNetwork(SandboxSpec spec, IReadOnlyList egressExecPrefix) => egressExecPrefix.Count > 0 || spec.AllowNetwork; + /// The proxy variables a child inherits from the worker (and may be handed by its task) to reach a model API through an egress proxy. + private static readonly string[] ProxyVariables = { "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy" }; + + /// + /// Drop the proxy variables from a SEALED launch's environment. Its only destination is its broker, on the + /// namespace's own gateway — a proxy is unreachable from there, and a CLI that honours the variables would send its + /// every model call to it and fail as if the provider were down. The per-run gateway is never in an operator's + /// NO_PROXY, so the variables must go rather than be amended. The worker's own hop to the provider keeps its proxy. + /// + internal static void WithoutProxiesWhenSealed(IDictionary environment, SandboxSpec spec, IReadOnlyList egressExecPrefix) + { + if (!SealedEgress(spec, egressExecPrefix)) return; + + foreach (var name in ProxyVariables) environment.Remove(name); + } + + /// Whether this launch runs inside a SEALED netns: a namespace prefix for a run whose network is off can only be the sealed one, since an allowlist is read only when network is granted. Read by the launch's confinement record, beside . + private static bool SealedEgress(SandboxSpec spec, IReadOnlyList egressExecPrefix) => !spec.AllowNetwork && egressExecPrefix.Count > 0; + /// /// The allowlist bwrap's OWN egress policy sees. Inside a filtered netns the namespace IS the enforcement, so the /// allowlist is withheld (bwrap must inherit that network, not re-derive its own and unshare it away). Like diff --git a/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Runners/LocalProcessRunner.NativeLaunch.cs b/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Runners/LocalProcessRunner.NativeLaunch.cs index 476592d62..63f743d55 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Runners/LocalProcessRunner.NativeLaunch.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/Sandbox/Runners/LocalProcessRunner.NativeLaunch.cs @@ -75,12 +75,83 @@ public async Task LaunchOrDiscoverAsync(SandboxLaunchRequest requ if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(directory, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); var record = await BindLaunchAsync(request, hash, directory, cancellationToken).ConfigureAwait(false); - // File existence is only a scheduling optimization. The independent bootstrap itself owns the create-only - // commitment, so two OS observers seeing "absent" cannot release two executions. - if (!File.Exists(NativeLaunchFiles.PathFor(directory, NativeLaunchProtocol.CommitmentFile))) - await StartBrokerAsync(new BrokerStart(request.SpoolKey, spec, spool, directory), cancellationToken).ConfigureAwait(false); + try + { + // File existence is only a scheduling optimization. The independent bootstrap itself owns the create-only + // commitment, so two OS observers seeing "absent" cannot release two executions. + if (!File.Exists(NativeLaunchFiles.PathFor(directory, NativeLaunchProtocol.CommitmentFile))) + await StartBrokerAsync(new BrokerStart(request.SpoolKey, spec, spool, directory), cancellationToken).ConfigureAwait(false); + + return await DiscoverHandleAsync(record, directory, spool, LaunchPatience, cancellationToken).ConfigureAwait(false); + } + catch + { + // A bootstrap that never released an execution leaves nothing to tear down the namespace and cgroup this + // slot was given: the launch fails before a handle exists, and every reaper keys on a handle. Only proof + // that nothing was released is enough — any other outcome may have run, and is left alone. + if (await LaunchProvedUnexecutedAsync(directory).ConfigureAwait(false)) await TearDownRejectedLaunchAsync(request.SpoolKey).ConfigureAwait(false); + throw; + } + } + + /// How long a failed launch waits for the bootstrap's receipt before concluding it cannot prove a rejection. The bootstrap writes it as it exits, which is what failed the launch here, so it is normally already there. + private static readonly TimeSpan RejectionReceiptPatience = TimeSpan.FromSeconds(2); + + /// + /// Whether this slot provably never released an execution. The bootstrap writes its ready receipt BEFORE it + /// releases the exec bootstrap, and an exec bootstrap without a complete release exits without exec, so two states + /// prove it: a rejected verdict, and a receipt still committed whose broker is dead (killed, or unable + /// to write its verdict to a full disk). It waits while the receipt is missing or still committed by a live + /// broker, which replaces it with its verdict as it exits. False for anything else or when the patience runs out: + /// an uncertain launch keeps its isolation rather than risk tearing it out from under a live agent. + /// + internal static async Task LaunchProvedUnexecutedAsync(string directory) + { + var watch = Stopwatch.StartNew(); + + while (true) + { + var receipt = ReadReceipt(directory, out var unreadable); - return await DiscoverHandleAsync(record, directory, spool, LaunchPatience, cancellationToken).ConfigureAwait(false); + if (unreadable) return false; + if (receipt?.State == "rejected") return true; + if (receipt is { State: "committed" } && BrokerDiedBeforeItsVerdict(directory, receipt.Broker)) return true; + if (receipt is not null && receipt.State != "committed" || watch.Elapsed >= RejectionReceiptPatience) return false; + + await Task.Delay(20).ConfigureAwait(false); + } + } + + /// + /// Whether died leaving its receipt at committed. The two facts are sampled in the + /// order that makes them proof: death FIRST, then the receipt again. The broker is the receipt's only writer, so a + /// committed read after it is known dead is its last word — whereas a receipt read before the liveness check + /// leaves a window in which it could write ready, release, and die. Internal, with the liveness read as a seam + /// (, by default), so a test can move the receipt at + /// the moment liveness is sampled and pin that order. + /// + internal static bool BrokerDiedBeforeItsVerdict(string directory, NativeProcessIdentity broker, Func? isAlive = null) + { + if ((isAlive ?? NativeProcess.IsAlive)(broker)) return false; + + return ReadReceipt(directory, out var unreadable) is { State: "committed" } after && !unreadable && NativeProcess.Same(after.Broker, broker); + } + + /// The slot's receipt, null while there is none yet; when one exists but cannot be read, which proves nothing. + private static NativeLaunchReceipt? ReadReceipt(string directory, out bool unreadable) + { + unreadable = false; + + try { return NativeLaunchFiles.Read(directory, NativeLaunchProtocol.ReceiptFile); } + catch (FileNotFoundException) { return null; } + catch (Exception error) when (error is JsonException or InvalidDataException or IOException or UnauthorizedAccessException) { unreadable = true; return null; } + } + + private static async Task TearDownRejectedLaunchAsync(string spoolKey) + { + await FilteredEgressNetns.TeardownAsync(spoolKey, CancellationToken.None).ConfigureAwait(false); + + if (CgroupResourceLimit.CgroupRoot is { } root) await CgroupResourceLimit.TeardownAsync(root, spoolKey, CancellationToken.None).ConfigureAwait(false); } /// @@ -149,6 +220,11 @@ private static async Task BindLaunchAsync(SandboxLaunchReque private async Task StartBrokerAsync(BrokerStart request, CancellationToken cancellationToken) { var binary = RunnerHostBinaryPath(); + + // The bootstrap's admission window opens at "owned" and must cover the whole cgroup + namespace setup below, so + // the one slow first-use cost — the seal probe — is paid before the window opens rather than inside it. + _ = SealableBrokerPort(request.Spec); + var info = new ProcessStartInfo(binary) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true }; info.ArgumentList.Add("broker"); info.ArgumentList.Add(request.Directory); using var process = new Process { StartInfo = info }; @@ -176,7 +252,7 @@ private async Task StartBrokerAsync(BrokerStart request, CancellationToken cance Spec = request.Spec, ReadOnlyPaths = request.Spec.ReadOnlyPaths, CaptureBudget = request.Spec.CaptureBudget, Command = command.FileName, Args = command.ArgumentList.ToArray(), WorkingDirectory = command.WorkingDirectory, Environment = command.Environment.ToDictionary(pair => pair.Key, pair => pair.Value), EgressNetnsKey = egressKey, CgroupRunKey = cgroupKey, - Confinement = BubblewrapSandbox.DeriveConfinement(BubblewrapSandbox.Available, BubblewrapSandbox.UnavailableReason, ShareNetwork(request.Spec, egress.ExecPrefix), EgressAllowlist(request.Spec, egress.ExecPrefix)), + Confinement = BubblewrapSandbox.DeriveConfinement(BubblewrapSandbox.Available, BubblewrapSandbox.UnavailableReason, ShareNetwork(request.Spec, egress.ExecPrefix), EgressAllowlist(request.Spec, egress.ExecPrefix), SealedEgress(request.Spec, egress.ExecPrefix)), }; // Measured BEFORE transmission is marked started, so a frame no pipe can carry is refused while the catch // below can still tear the netns and cgroup down, and the broker reads EOF and releases its slot as rejected. diff --git a/backend/src/CodeSpace.Core/Services/Sessions/Room/RoomProjector.cs b/backend/src/CodeSpace.Core/Services/Sessions/Room/RoomProjector.cs index 250db4ceb..f820199b3 100644 --- a/backend/src/CodeSpace.Core/Services/Sessions/Room/RoomProjector.cs +++ b/backend/src/CodeSpace.Core/Services/Sessions/Room/RoomProjector.cs @@ -1000,12 +1000,12 @@ await _db.AgentRun.AsNoTracking() .OrderBy(ConfinementRank).ThenBy(c => c.Reason ?? "", StringComparer.Ordinal) .FirstOrDefault(); - /// Ascending strength: anything not confined is 0 (the reader's "yes, one of them could reach the network"), confinement without a severed netns 1, full severance 2. + /// Ascending strength: anything not confined is 0 (the reader's "yes, one of them could reach the network"), confinement without a severed netns 1, a namespace sealed to the run's model broker 2 — severed from everything but that one route, so weaker than — full severance 3. private static int ConfinementRank(SandboxConfinement confinement) => - confinement.Outcome != SandboxConfinementOutcome.Confined ? 0 : confinement.NetworkSevered ? 2 : 1; + confinement.Outcome != SandboxConfinementOutcome.Confined ? 0 : confinement.EgressSealedToBroker ? 2 : confinement.NetworkSevered ? 3 : 1; /// - /// One producer's own posture, named at the SAME three strengths orders the + /// One producer's own posture, named at the SAME four strengths orders the /// run-level fold by — so the per-artifact word and the turn's sentence can never disagree about what a record /// means. An absent record is , never the confined value a reader /// would take as safety nobody evidenced. @@ -1014,6 +1014,7 @@ private static int ConfinementRank(SandboxConfinement confinement) => { null => RoomConfinementPosture.Unknown, { Outcome: not SandboxConfinementOutcome.Confined } => RoomConfinementPosture.Unconfined, + { EgressSealedToBroker: true } => RoomConfinementPosture.ConfinedEgressSealedToBroker, { NetworkSevered: true } => RoomConfinementPosture.ConfinedNetworkSevered, _ => RoomConfinementPosture.Confined, }; diff --git a/backend/src/CodeSpace.Messages/Agents/SandboxConfinement.cs b/backend/src/CodeSpace.Messages/Agents/SandboxConfinement.cs index cd0f6ca1a..6fcd45add 100644 --- a/backend/src/CodeSpace.Messages/Agents/SandboxConfinement.cs +++ b/backend/src/CodeSpace.Messages/Agents/SandboxConfinement.cs @@ -42,9 +42,17 @@ public sealed record SandboxConfinement /// Why the host could not confine — one of the Reason* constants. Null for every outcome but . public string? Reason { get; init; } - /// Whether the launch put the agent in a fresh EMPTY net namespace (--unshare-net) — true when confinement applied AND its egress policy came out anything but full: the run's network was off, OR it asked for an allowlist the sandbox cannot yet enforce and so failed closed. False for a plain shared-network run and for every unconfined one. + /// Whether the launch severed the agent from the network — a fresh EMPTY net namespace (--unshare-net), or one SEALED to its model broker (, which says which) — true when confinement applied AND its egress policy came out anything but full: the run's network was off, OR it asked for an allowlist the sandbox cannot yet enforce and so failed closed. False for a plain shared-network run and for every unconfined one. public bool NetworkSevered { get; init; } + /// + /// Whether the run's severed network was a SEALED namespace rather than an empty one: no route, no NAT and no DNS, + /// with exactly one reachable destination — the run's own model-credential broker on the namespace's gateway. The + /// shape a network-off run with a brokered model gets where the host can build it, because an empty namespace + /// would cut that run off from its model too. Implies ; false for every other run. + /// + public bool EgressSealedToBroker { get; init; } + /// /// Whether the run's model credential was BROKERED (true) or placed in the sandbox's environment as the tenant's /// own provider key (false). Null when the launch injected no credential at all — a harness that authenticates diff --git a/backend/src/CodeSpace.Messages/Agents/SandboxHandle.cs b/backend/src/CodeSpace.Messages/Agents/SandboxHandle.cs index 882918d13..ad9e7f43c 100644 --- a/backend/src/CodeSpace.Messages/Agents/SandboxHandle.cs +++ b/backend/src/CodeSpace.Messages/Agents/SandboxHandle.cs @@ -200,7 +200,8 @@ public sealed record SandboxHandle /// /// The key of the filtered-egress network namespace this run was launched inside (B3.2b) — non-null ONLY when a - /// deny-by-default allowlist was enforceable and a netns was set up. It is the teardown handle: the netns / veth / + /// deny-by-default allowlist was enforceable, or a network-off run was sealed to its model broker, and a netns was + /// set up. It is the teardown handle: the netns / veth / /// nft-table names are derived purely from it, so a reap (or a re-attach after a restart, from a DIFFERENT worker /// process on the same host) tears the namespace down with no setup-time state — the tools it drives are local, so /// the same-host boundary in the type remarks applies. Null when the run had no allowlist or the runner couldn't diff --git a/backend/src/CodeSpace.Messages/Agents/SandboxSpec.cs b/backend/src/CodeSpace.Messages/Agents/SandboxSpec.cs index 23a913f8b..f10f1632a 100644 --- a/backend/src/CodeSpace.Messages/Agents/SandboxSpec.cs +++ b/backend/src/CodeSpace.Messages/Agents/SandboxSpec.cs @@ -82,8 +82,9 @@ public sealed record SandboxSpec /// /// Whether the command may reach the network. false (the DEFAULT) → the sandbox runner severs egress - /// entirely (a fresh network namespace with only loopback), so a confined agent cannot reach cloud-metadata, the - /// LAN, or exfiltrate over the internet. true → the host network is shared, UNLESS + /// entirely (a fresh network namespace with only loopback — or, for a run whose model is brokered, one sealed to + /// that broker: see ), so a confined agent cannot reach cloud-metadata, the LAN, or + /// exfiltrate over the internet. true → the host network is shared, UNLESS /// narrows it. Enforced only by a sandboxing runner; a bare-process runner cannot /// honour it. /// @@ -105,6 +106,19 @@ public sealed record SandboxSpec /// public IReadOnlyList? EgressAllowlist { get; init; } + /// + /// The port of this run's model-credential broker lease, set only for a run whose network is OFF and whose model + /// is reached through that broker. Such a run cannot be severed from everything the way + /// otherwise asks — the broker would be cut off with the rest, and the agent could reach no model at all — so a + /// confining runner able to build one runs it in a SEALED namespace instead: no route, no NAT, no DNS, and exactly + /// one reachable destination, this port on the namespace's gateway. A runner that cannot seal keeps severing. + /// + /// Set by AgentRunExecutor.ApplySealedEgress at the executor's one spec choke point. Null (every other + /// spec) ⇒ omitted from the JSON, so the spec serializes and hashes as it did before the field existed. + /// + [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + public int? ModelBrokerPort { get; init; } + /// /// Max processes the command + its descendants may spawn (RLIMIT_NPROC) — a fork-bomb cap so a runaway agent /// cannot exhaust the worker's process table. 0 = unlimited. Enforced by a sandboxing runner. diff --git a/backend/src/CodeSpace.Messages/Dtos/Sessions/Room/RoomArtifactProducer.cs b/backend/src/CodeSpace.Messages/Dtos/Sessions/Room/RoomArtifactProducer.cs index b624662cf..b7424069c 100644 --- a/backend/src/CodeSpace.Messages/Dtos/Sessions/Room/RoomArtifactProducer.cs +++ b/backend/src/CodeSpace.Messages/Dtos/Sessions/Room/RoomArtifactProducer.cs @@ -68,4 +68,7 @@ public enum RoomConfinementPosture /// Confined AND handed a fresh empty net namespace — the strongest posture a producer can record. ConfinedNetworkSevered, + + /// Confined, with a network namespace SEALED to the run's model broker: severed from everything but that one route, so between and in strength. Declared last only so the earlier values keep their numbers. + ConfinedEgressSealedToBroker, } diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunExecutorCredentialBrokerTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunExecutorCredentialBrokerTests.cs index 2c9468ba2..3a60b1c21 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunExecutorCredentialBrokerTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunExecutorCredentialBrokerTests.cs @@ -103,6 +103,71 @@ public async Task A_brokered_run_records_that_it_was_brokered_and_says_nothing_a .ShouldNotContain(AgentAutonomyPolicy.DirectModelCredentialCaveat, customMessage: "a brokered run must not disclose a direct injection it did not do"); } + [Theory] + [InlineData(AgentAutonomyLevel.Standard, true)] // network off: the port a confining runner seals the run to its broker with + [InlineData(AgentAutonomyLevel.Trusted, false)] // network on already reaches its broker; nothing to seal + public async Task A_brokered_network_off_launch_carries_its_lease_port_to_the_runner(AgentAutonomyLevel autonomy, bool expectPort) + { + if (OperatingSystem.IsWindows()) return; + + // The WIRING pin: ApplySealedEgress's unit tests would pass even if HardenSpec never fed it the lease, and a + // network-off brokered run would then be severed from its broker on every host that confines. + var teamId = await SeedTeamAsync(); + var credId = await SeedModelCredentialAsync(teamId, BrokeredProvider, "sk-sealed-port-fixture"); + var runId = await CreateTaskRunAsync(teamId, new AgentTask { Goal = "scripted", Harness = "scripted-projector", Model = "test-model", ModelCredentialId = credId, Autonomy = autonomy, Permissions = AgentAutonomyPolicy.Derive(autonomy) }); + var runner = new SpecRecordingDurableRunner(); + + using var broker = new LoopbackModelCredentialBroker(); + var harness = new BrokerableScriptedHarness(BrokeredProvider, "echo done"); + + await ExecuteAsync(runId, harness, runners: new SandboxRunnerRegistry(new ISandboxRunner[] { runner }), credentialBroker: broker); + + var launched = runner.Launched.ShouldNotBeNull("the executor must have launched — a null spec means it failed before reaching the runner"); + var leasePort = new Uri(harness.BuiltTask!.Environment["SCRIPTED_BASE_URL"].Replace(SandboxSpec.ModelBrokerHostToken, "127.0.0.1", StringComparison.Ordinal)).Port; + + launched.ModelBrokerPort.ShouldBe(expectPort ? leasePort : null, $"a {autonomy} brokered run must {(expectPort ? "" : "not ")}hand the runner the port of the lease its CLI was pointed at"); + } + + [Fact] + public async Task An_unbrokered_network_off_launch_carries_no_broker_port() + { + if (OperatingSystem.IsWindows()) return; + + var teamId = await SeedTeamAsync(); + var runId = await CreateScriptedRunAsync(teamId); + var runner = new SpecRecordingDurableRunner(); + + await ExecuteAsync(runId, new ScriptedHarness("printf 'one\\n'"), runners: new SandboxRunnerRegistry(new ISandboxRunner[] { runner })); + + runner.Launched.ShouldNotBeNull().ModelBrokerPort.ShouldBeNull("a run with no broker lease has nothing to be sealed to, so it stays severed"); + } + + [Fact] + public async Task A_sealed_launch_s_record_survives_the_column_and_reads_back_as_sealed() + { + // The record is written by the runner and read back by the journal and the Room off agent_run.sandbox_confinement; + // the sealed fact has to survive that column, or a sealed run reads as a merely severed one. + var sealedRecord = new SandboxConfinement { Outcome = SandboxConfinementOutcome.Confined, NetworkSevered = true, EgressSealedToBroker = true, ModelCredentialBrokered = true }; + var teamId = await SeedTeamAsync(); + var runId = await CreateScriptedRunAsync(teamId); + + using (var write = _fixture.BeginScope()) + { + var db = write.Resolve(); + var row = await db.AgentRun.SingleAsync(r => r.Id == runId); + row.SandboxConfinementJson = JsonSerializer.Serialize(sealedRecord, AgentJson.Options); + await db.SaveChangesAsync(); + } + + using var read = _fixture.BeginScope(); + var stored = await read.Resolve().AgentRun.AsNoTracking().Where(r => r.Id == runId).Select(r => r.SandboxConfinementJson).SingleAsync(); + var roundTripped = JsonSerializer.Deserialize(stored!, AgentJson.Options).ShouldNotBeNull(); + + roundTripped.EgressSealedToBroker.ShouldBeTrue("jsonb normalizes what it stores; the sealed fact must come back out of it"); + AgentAutonomyPolicy.DescribeNetwork(AgentAutonomyLevel.Standard, AgentAutonomyLevel.Trusted, AgentAutonomyLevel.Unleashed, roundTripped) + .ShouldBe("Network: off (Standard) — confined: egress sealed to the run's model broker"); + } + [Fact] public async Task A_run_whose_credential_cannot_be_brokered_discloses_the_direct_injection() { diff --git a/backend/tests/CodeSpace.SandboxTests/ModelCredentialBrokerNetnsE2ETests.cs b/backend/tests/CodeSpace.SandboxTests/ModelCredentialBrokerNetnsE2ETests.cs index f7e266416..2138ca8e0 100644 --- a/backend/tests/CodeSpace.SandboxTests/ModelCredentialBrokerNetnsE2ETests.cs +++ b/backend/tests/CodeSpace.SandboxTests/ModelCredentialBrokerNetnsE2ETests.cs @@ -13,12 +13,12 @@ namespace CodeSpace.SandboxTests; /// for real ONLY in the privileged sandbox-isolation CI job; elsewhere /// is false and it degrade-skips. /// -/// The claim it settles. A sealed run's whole point is that its egress allowlist is the only way out — -/// so "the broker is reachable from inside" cannot be argued from the code, it has to be observed. The allowlist here -/// is deliberately EMPTY: the namespace can reach nothing on the internet, and the broker still answers, because a -/// packet addressed to the host's own veth address is delivered locally (INPUT) rather than FORWARDED, and the plan's -/// filter is a forward hook. If that ever stops being true, every sealed brokered run loses its model and this test -/// is where it shows. +/// The claim it settles. A sealed run's whole point is that its broker is the only way out — so "the +/// broker is reachable from inside" cannot be argued from the code, it has to be observed. The namespace is the +/// production sealed one (): no route, no NAT, no DNS, and an input +/// filter admitting only the lease's own port on the gateway. The broker still answers, because a packet addressed to +/// the host's own veth address is delivered locally (INPUT) and that one port is what the filter admits. If that ever +/// stops being true, every sealed brokered run loses its model and this test is where it shows. /// /// The second claim is the flip side: the bearer the sandbox holds is NOT the tenant's key. Sent straight to the /// provider it buys nothing, so a token that escapes a run is not a credential. @@ -28,8 +28,10 @@ public sealed class ModelCredentialBrokerNetnsE2ETests { private const string ProviderHost = "api.anthropic.com"; - [Fact] - public async Task A_sealed_run_reaches_its_broker_and_is_refused_the_moment_the_lease_is_revoked() + [Theory] + [InlineData(true)] // the network-off run's sealed namespace — its one destination is this lease's port + [InlineData(false)] // an allowlist run's namespace with nothing allowed — reaches the worker through the same gateway + public async Task A_namespaced_run_reaches_its_broker_and_is_refused_the_moment_the_lease_is_revoked(bool sealedToBroker) { if (!FilteredEgressNetns.IsSupported) return; // no ip/nft (macOS dev / non-privileged) → the privileged CI job is authoritative @@ -43,13 +45,22 @@ public async Task A_sealed_run_reaches_its_broker_and_is_refused_the_moment_the_ brokered.ShouldNotBeNull("the broker must be able to listen on a host that can build filtered-egress namespaces — a sealed run has no other route to a model"); - // An EMPTY allowlist: this namespace can reach nothing on the internet. See the class remarks. + // Both production namespaces a brokered run is launched into: the sealed one, and the allowlist one, whose + // broker is reached as a local delivery the forward filter never sees. See the class remarks. var netnsKey = Guid.NewGuid().ToString("N"); - var setup = await FilteredEgressNetns.SetupAsync(netnsKey, Array.Empty(), timeoutSeconds: 20, CancellationToken.None); + var setup = sealedToBroker + ? await FilteredEgressNetns.SetupSealedAsync(netnsKey, brokered!.RebindPort!.Value, timeoutSeconds: 20, CancellationToken.None) + : await FilteredEgressNetns.SetupAsync(netnsKey, Array.Empty(), timeoutSeconds: 20, CancellationToken.None); + + var plan = sealedToBroker ? "sealed" : "allowlist"; + var table = sealedToBroker ? $"inet {FilteredEgressPlan.NamespaceFor(netnsKey)}" : $"ip {FilteredEgressPlan.NamespaceFor(netnsKey)}"; + var why = sealedToBroker + ? "a host-destined packet is INPUT, and the sealed input filter must admit exactly this lease's port" + : "a host-destined packet is INPUT, which the allowlist plan's forward filter never sees, so no allowlist entry is needed"; try { - setup.SetupOk.ShouldBeTrue($"the filtered netns must set up cleanly; setup error: {setup.SetupError}"); + setup.SetupOk.ShouldBeTrue($"the {plan} netns must set up cleanly; setup error: {setup.SetupError}"); setup.HostIp.ShouldNotBeNullOrWhiteSpace("the setup must report its gateway address — it is the only address a process inside the namespace can reach this worker at"); // Resolve the broker's address through the PRODUCTION substitution the runner performs at launch, so the @@ -57,7 +68,7 @@ public async Task A_sealed_run_reaches_its_broker_and_is_refused_the_moment_the_ var url = ReachableUrl(brokered!, setup.HostIp!) + "/v1/messages"; (await CurlInNetnsAsync(setup.ExecPrefix, url, brokered!.RunToken)).ShouldBe("200", - customMessage: $"a sealed run must reach its broker at {setup.HostIp} with an EMPTY egress allowlist — if this is not 200, check by hand: `ip netns exec {FilteredEgressPlan.NamespaceFor(netnsKey)} curl -v {url}`. A host-destined packet is INPUT, not FORWARD, so the allowlist must not be involved"); + customMessage: $"a run in the {plan} netns must reach its broker at {setup.HostIp} — if this is not 200, check by hand: `ip netns exec {FilteredEgressPlan.NamespaceFor(netnsKey)} curl -v {url}` and `nft list table {table}`. {why}"); var relayedBeforeRevoke = upstream.Calls; @@ -71,7 +82,7 @@ public async Task A_sealed_run_reaches_its_broker_and_is_refused_the_moment_the_ var (exit, status) = await CurlAsync(setup.ExecPrefix, url, brokered.RunToken); exit.ShouldBe(CurlCouldNotConnect, - customMessage: $"after a revoke nothing may answer at {url} from inside the namespace — curl must fail to connect (7), and got exit {exit} (status '{status}'). Exit 0 means something is STILL LISTENING on the revoked lease's port; exit 28 means the packet is being dropped rather than rejected, which is a netns/filter change, not a brokerage one. Check by hand: `ip netns exec {FilteredEgressPlan.NamespaceFor(netnsKey)} curl -v {url}`"); + customMessage: $"after a revoke nothing may answer at {url} from inside the namespace — curl must fail to connect (7), and got exit {exit} (status '{status}'). Exit 0 means something is STILL LISTENING on the revoked lease's port; exit 28 means the packet is being dropped rather than rejected — neither plan's filter drops this port, so that is a netns/filter change, not a brokerage one. Check by hand: `ip netns exec {FilteredEgressPlan.NamespaceFor(netnsKey)} curl -v {url}`"); upstream.Calls.ShouldBe(relayedBeforeRevoke, "and nothing may reach the provider after the withdrawal — that, not which error the sandbox sees, is what decides whether a cancelled run can still spend the tenant's key"); @@ -90,20 +101,23 @@ public async Task A_sealed_run_reaches_its_broker_again_after_the_worker_that_mi var runId = Guid.NewGuid(); var teamId = Guid.NewGuid(); var netnsKey = Guid.NewGuid().ToString("N"); - var setup = await FilteredEgressNetns.SetupAsync(netnsKey, Array.Empty(), timeoutSeconds: 20, CancellationToken.None); try { - setup.SetupOk.ShouldBeTrue($"the filtered netns must set up cleanly; setup error: {setup.SetupError}"); - setup.HostIp.ShouldNotBeNullOrWhiteSpace("the setup must report its gateway address — it is the only address a process inside the namespace can reach this worker at"); - BrokeredModelCredential brokered; + FilteredEgressNetns.SetupResult setup; string url; - // Worker A mints the address, proves it works from inside the sealed namespace, and then GOES AWAY. + // Worker A mints the address, proves it works from inside the sealed namespace, and then GOES AWAY. The + // namespace is sealed to the port worker A's lease holds — the port the re-bind below must take again. using (var workerA = LoopbackModelCredentialBroker.ForTest(new AlwaysOkUpstream())) { brokered = (await workerA.OpenAsync(LeaseFor(runId, teamId), CancellationToken.None)).ShouldNotBeNull(); + setup = await FilteredEgressNetns.SetupSealedAsync(netnsKey, brokered.RebindPort!.Value, timeoutSeconds: 20, CancellationToken.None); + + setup.SetupOk.ShouldBeTrue($"the sealed netns must set up cleanly; setup error: {setup.SetupError}"); + setup.HostIp.ShouldNotBeNullOrWhiteSpace("the setup must report its gateway address — it is the only address a process inside the namespace can reach this worker at"); + url = ReachableUrl(brokered, setup.HostIp!) + "/v1/messages"; (await CurlInNetnsAsync(setup.ExecPrefix, url, brokered.RunToken)).ShouldBe("200", "precondition: the sealed run reaches its broker while the worker that minted it holds the address"); diff --git a/backend/tests/CodeSpace.SandboxTests/ReviewerReadsItsDiffE2ETests.cs b/backend/tests/CodeSpace.SandboxTests/ReviewerReadsItsDiffE2ETests.cs index 41235809f..dcba2c27c 100644 --- a/backend/tests/CodeSpace.SandboxTests/ReviewerReadsItsDiffE2ETests.cs +++ b/backend/tests/CodeSpace.SandboxTests/ReviewerReadsItsDiffE2ETests.cs @@ -27,11 +27,10 @@ namespace CodeSpace.SandboxTests; /// the one tool call a reviewer would make. That is deliberate: the question is whether the CLI's permission mode /// RUNS the command, not whether a model would choose to. /// -/// One stated deviation. The read arm runs the Confined posture with the network ON: under bubblewrap a -/// Network=Off run is severed from everything, the broker included, so it could not reach even a local model. That is -/// not assumed — the second arm pins it, and the argv the CLI sees is identical either way (asserted), so the read -/// arm answers the permission question the production posture would face. What the network-off arm finds is itself -/// the finding: a Confined reviewer on a confining worker cannot reach its model today. +/// Every arm runs its tier's production posture, network off included. Under bubblewrap a network-off run whose +/// model is brokered runs in a namespace sealed to that broker (AgentRunExecutor.ApplySealedEgress), so it +/// reaches its model and nothing else; before that seal it was severed from the broker too and reached no model at +/// all, which is why these arms once had to turn the network on to ask anything. /// /// What it found on Linux, pinned so that a fix has to flip it deliberately: both CLIs read the diff under our /// bubblewrap, over a workspace the kernel mounts read-only for a Confined run. Codex does so only because the runner @@ -105,12 +104,9 @@ private async Task ReadsTheDiffAsync(string harnessKind, AgentAutonomyLevel tier var brokered = await OpenLeaseAsync(broker); var production = AgentAutonomyPolicy.Derive(tier); - var task = ReviewTask(harnessKind, repo, production with { Network = AgentNetworkAccess.On }, Brokered(harness, brokered)); + var task = ReviewTask(harnessKind, repo, production, Brokered(harness, brokered)); - harness.BuildInvocation(task).Args.ShouldBe(harness.BuildInvocation(task with { Permissions = production }).Args, - customMessage: $"fixture check: the CLI must see the same argv with the network on as the production {tier} posture gives it, or this arm answers a different question"); - - var run = await RunAsync(harness, task); + var run = await RunAsync(harness, task, brokered.RebindPort); run.Spec.ReadOnlyWorkingDirectory.ShouldBe(production.WriteScope == AgentWriteScope.ReadOnly, $"fixture check: a {tier} reviewer must be launched over the workspace mount its write scope gives it, or this arm does not show git reads surviving it"); @@ -156,9 +152,9 @@ public async Task A_confined_reviewer_cannot_write_its_workspace(string harnessK using var broker = LoopbackModelCredentialBroker.ForTest(upstream); var brokered = await OpenLeaseAsync(broker); - var task = ReviewTask(harnessKind, repo, AgentAutonomyPolicy.Derive(AgentAutonomyLevel.Confined) with { Network = AgentNetworkAccess.On }, Brokered(harness, brokered)); + var task = ReviewTask(harnessKind, repo, AgentAutonomyPolicy.Derive(AgentAutonomyLevel.Confined), Brokered(harness, brokered)); - var run = await RunAsync(harness, task); + var run = await RunAsync(harness, task, brokered.RebindPort); var fedBack = ToolOutputs(upstream.Requests); @@ -202,11 +198,11 @@ public async Task A_standard_codex_writes_its_workspace_under_our_confinement_bu using var broker = LoopbackModelCredentialBroker.ForTest(upstream); var brokered = await OpenLeaseAsync(broker); - var task = ReviewTask(harnessKind, repo, AgentAutonomyPolicy.Derive(AgentAutonomyLevel.Standard) with { Network = AgentNetworkAccess.On }, Brokered(harness, brokered)) with { Goal = "Change app.txt." }; + var task = ReviewTask(harnessKind, repo, AgentAutonomyPolicy.Derive(AgentAutonomyLevel.Standard), Brokered(harness, brokered)) with { Goal = "Change app.txt." }; ReviewRun run; - try { run = await RunAsync(harness, task); } + try { run = await RunAsync(harness, task, brokered.RebindPort); } finally { if (File.Exists(systemProbe)) File.Delete(systemProbe); } // a failed refusal must not leave the probe behind for the next run run.Result.Status.ShouldBe(SandboxStatus.Success, customMessage: $"the Standard Codex run did not finish cleanly (exit {run.Result.ExitCode}); stderr: {Tail(run.Result.Stderr)}; last tool output: {Tail(ToolOutputs(upstream.Requests), 600)}"); @@ -221,19 +217,24 @@ public async Task A_standard_codex_writes_its_workspace_under_our_confinement_bu [Theory] [InlineData(ClaudeCodeHarness.HarnessKind)] [InlineData(CodexHarness.HarnessKind)] - public async Task A_network_off_reviewer_under_confinement_cannot_reach_its_model(string harnessKind) + public async Task A_network_off_reviewer_reaches_its_model_through_the_sealed_namespace(string harnessKind) { + // The durable launch every agent run takes, network off, under confinement: the run must be launched inside a + // namespace sealed to its broker (recorded on its handle), reach its model through it, read the diff, and leave + // no namespace behind. Before the seal this arm pinned the opposite — a Confined reviewer reached no model. var harness = HarnessFor(harnessKind); if (!Armed(harnessKind) || OperatingSystem.IsWindows()) return; if (BubblewrapSandbox.Available is null) { - // Only confinement severs the network: an unconfined host would let this run reach the broker and say nothing. + // Only confinement seals the network: an unconfined host would let this run reach the broker and say nothing. BubblewrapSandbox.IsRequired.ShouldBeFalse("Sandbox:RequireConfinement is set but this host cannot sandbox (bwrap/userns) — the E2E cannot prove what confinement does here"); return; } + FilteredEgressNetns.CanSeal.ShouldBeTrue("this confining host could not build a throwaway namespace, so every network-off brokered run on it is severed from its model"); + await RequirePinnedBinaryAsync(harness, harnessKind); var repo = NewReviewRepository(); @@ -241,14 +242,26 @@ public async Task A_network_off_reviewer_under_confinement_cannot_reach_its_mode using var broker = LoopbackModelCredentialBroker.ForTest(upstream); var brokered = await OpenLeaseAsync(broker); - var task = ReviewTask(harnessKind, repo, AgentAutonomyPolicy.Derive(AgentAutonomyLevel.Confined), Brokered(harness, brokered)) with { TimeoutSeconds = 120 }; + var task = ReviewTask(harnessKind, repo, AgentAutonomyPolicy.Derive(AgentAutonomyLevel.Confined), Brokered(harness, brokered)); + var spec = ProductionSpec(harness, task, brokered.RebindPort); + var key = Guid.NewGuid().ToString("N"); + var runner = new LocalProcessRunner(); + var lines = new List(); + var clock = Stopwatch.StartNew(); - var run = await RunAsync(harness, task); + var handle = await runner.LaunchAsync(spec, key, CancellationToken.None); + _directories.Add(handle.SpoolDirectory); - upstream.Requests.ShouldBeEmpty(customMessage: $"a Network=Off run under bubblewrap reached its model — confinement no longer severs it from the broker, so this arm's finding (a Confined reviewer cannot reach its model) is out of date and the read arm's network-on deviation can go. Requests: {Describe(upstream.Requests)}"); - run.Result.Status.ShouldNotBe(SandboxStatus.Success, customMessage: "a reviewer that reached no model cannot have finished its review"); + using var budget = new CancellationTokenSource(TimeSpan.FromSeconds((task.TimeoutSeconds ?? 300) + 60)); + var result = await runner.AttachAsync(handle, (frame, _) => { lines.Add(frame.Text); return Task.CompletedTask; }, budget.Token); - output.WriteLine($"{RanMarker} network-off {harnessKind} status={run.Result.Status} exit={run.Result.ExitCode}"); + handle.EgressNetnsKey.ShouldBe(key, "a network-off brokered run must be launched inside a sealed namespace keyed by the run"); + handle.Confinement.ShouldNotBeNull().EgressSealedToBroker.ShouldBeTrue("the launch must record that the run was sealed to its broker"); + result.Status.ShouldBe(SandboxStatus.Success, customMessage: $"the {harnessKind} reviewer did not finish cleanly through the sealed namespace (exit {result.ExitCode}); stderr: {Tail(result.Stderr)}; requests the model saw: {Describe(upstream.Requests)}"); + upstream.Requests.ShouldContain(r => r.Body.Contains($"MARKER-NEW-{repo.Nonce}", StringComparison.Ordinal), $"the diff must reach the model through the sealed namespace; last tool output: {Tail(ToolOutputs(upstream.Requests), 600)}"); + (await GitAsync(repo.Directory, "status --porcelain")).ShouldBeEmpty("a Confined reviewer leaves the workspace exactly as it found it"); + + output.WriteLine($"{RanMarker} network-off-sealed {harnessKind} seconds={clock.Elapsed.TotalSeconds:F1}"); } public void Dispose() @@ -312,10 +325,13 @@ private static async Task OpenLeaseAsync(LoopbackModelC Environment = new Dictionary(brokeredEnvironment) { ["HOME"] = NewDirectory("review-home") }, }; - /// Launch the task the way the executor does: the harness invocation with the run's write scope applied, so a read-only run's workspace is mounted read-only wherever the host confines. - private static async Task RunAsync(IAgentHarness harness, AgentTask task) + /// The spec the executor would hand the runner: the harness invocation sealed to the run's broker lease when its network is off, and with its write scope applied, so a read-only run's workspace is mounted read-only wherever the host confines. + private static SandboxSpec ProductionSpec(IAgentHarness harness, AgentTask task, int? brokerPort) => + AgentRunExecutor.ApplyWriteScope(AgentRunExecutor.ApplySealedEgress(harness.BuildInvocation(task), task.Permissions, brokerPort), task.Permissions); + + private static async Task RunAsync(IAgentHarness harness, AgentTask task, int? brokerPort) { - var spec = AgentRunExecutor.ApplyWriteScope(harness.BuildInvocation(task), task.Permissions); + var spec = ProductionSpec(harness, task, brokerPort); var lines = new List(); using var budget = new CancellationTokenSource(TimeSpan.FromSeconds((task.TimeoutSeconds ?? 300) + 60)); diff --git a/backend/tests/CodeSpace.SandboxTests/SealedEgressE2ETests.cs b/backend/tests/CodeSpace.SandboxTests/SealedEgressE2ETests.cs new file mode 100644 index 000000000..bfcc2c417 --- /dev/null +++ b/backend/tests/CodeSpace.SandboxTests/SealedEgressE2ETests.cs @@ -0,0 +1,339 @@ +using System.Diagnostics; +using System.Globalization; +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text.Json; +using CodeSpace.Core.Services.Agents.Credentials.Broker; +using CodeSpace.Core.Services.Agents.Sandbox; +using CodeSpace.Core.Services.Agents.Sandbox.Isolation; +using CodeSpace.Core.Services.Agents.Sandbox.Runners; +using CodeSpace.Messages.Agents; +using Shouldly; +using Xunit.Abstractions; + +namespace CodeSpace.SandboxTests; + +/// +/// 🟢 Sandbox isolation E2E (high fidelity, Rule 12): a network-off run whose model is brokered, launched by the REAL +/// under bubblewrap, runs in a namespace SEALED to its broker — and a probe from inside +/// it, through the real chain (ip netns exec → prlimit → bwrap → python3), observes exactly one open door: the +/// real broker answers, while the internet, DNS over TCP and UDP, and another listener on the worker's own gateway +/// address all stay shut. The allowlist plan with no IPs would fail three of those (it accepts DNS anywhere, NATs out, +/// and has no input filter), and plain severing fails the first — which is why a network-off brokered run on a +/// confining host reached no model before this. +/// +/// Needs bwrap + ip + nft + the privilege to build a namespace, 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 SealedEgressE2ETests(ITestOutputHelper output) : IDisposable +{ + /// Printed by every arm that actually ran; the sandbox lane requires one per arm in the test output. + public const string RanMarker = "[sealed-egress-e2e] ran"; + + private readonly List _spoolDirs = []; + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task A_network_off_brokered_run_reaches_its_broker_and_nothing_else(bool durable) + { + if (!Seals()) return; + + using var broker = LoopbackModelCredentialBroker.ForTest(new AlwaysOkUpstream()); + var brokered = (await broker.OpenAsync(Lease(), CancellationToken.None)).ShouldNotBeNull("the broker must be able to listen on a host that seals — a sealed run has no other route to a model"); + + using var otherListener = new TcpListener(IPAddress.Any, 0); + otherListener.Start(); + + var spec = new SandboxSpec + { + Command = "/usr/bin/python3", + Args = ["-c", ProbeScript], + AllowNetwork = false, + ModelBrokerPort = brokered.RebindPort, + // An egress proxy the worker (or the task) carries, as a proxied deployment would: the sealed namespace + // cannot reach it, so the launch must drop it — which the probe reports directly, since the broker's NO_PROXY + // exemption would let urllib past it even if the drop were gone. + Environment = new Dictionary { ["BROKER_URL"] = brokered.BaseUrl, ["RUN_TOKEN"] = brokered.RunToken, ["OTHER_PORT"] = ((IPEndPoint)otherListener.LocalEndpoint).Port.ToString(), ["HTTP_PROXY"] = "http://10.255.255.1:3128", ["http_proxy"] = "http://10.255.255.1:3128" }, + TimeoutSeconds = 60, + }; + + var (result, probe) = durable ? await RunDurableAsync(spec) : await RunAsync(spec); + + result.Status.ShouldBe(SandboxStatus.Success, $"the probe itself must run to its end inside the sealed namespace; stderr: {result.Stderr}"); + probe["broker"].ShouldBe("200", $"the run's own broker is the one destination a sealed run must reach — directly, not through the proxy it was handed; probe: {Describe(probe)}"); + probe["proxies"].ShouldBe("none", $"a sealed launch drops the proxy variables outright — the broker answering is not enough, since the NO_PROXY exemption alone lets urllib past a proxy a stricter reader would still use; probe: {Describe(probe)}"); + probe["internet"].ShouldNotBe("open", $"a sealed run must not reach the internet; probe: {Describe(probe)}"); + probe["dns_tcp"].ShouldNotBe("open", $"no DNS over TCP — a resolver is a tunnel; probe: {Describe(probe)}"); + probe["dns_udp"].ShouldNotBe("answered", $"no DNS over UDP either; probe: {Describe(probe)}"); + probe["gateway_other"].ShouldNotBe("open", $"another listener on the worker's own gateway address (its API, another run's broker) must stay shut; probe: {Describe(probe)}"); + + output.WriteLine($"{RanMarker} {(durable ? "durable" : "non-durable")} {Describe(probe)}"); + } + + [Fact] + public async Task A_sealed_namespace_drops_the_gateway_over_ipv6_link_local_too() + { + if (!Seals()) return; + + // Only the host knows its veth's link-local address, so this arm drives the namespace directly rather than + // through a launch. A v4-only table would let this through; the sealed table is inet. Two things keep the arm + // honest: it waits out duplicate-address detection (a tentative address refuses everything, table or not), and + // it then deletes the table and connects again — the probe must get through without it, or its refusal proved + // nothing about the table. + var key = Guid.NewGuid().ToString("N"); + var setup = await FilteredEgressNetns.SetupSealedAsync(key, brokerPort: 9, timeoutSeconds: 20, CancellationToken.None); + + try + { + setup.SetupOk.ShouldBeTrue($"the sealed namespace must set up on this host: {setup.SetupError}"); + + // The veth names are the run id's alone, so any lease names them the way the setup above did. + var names = FilteredEgressPlan.BuildSealed(key, 9, new EgressSubnetAllocator.Lease { Cidr = "0.0.0.0/30", HostIp = "0.0.0.1", NsIp = "0.0.0.2" }); + + if (await SettledLinkLocalAsync(["ip", "-6", "-o", "addr", "show", "dev", names.VethHost, "scope", "link"]) is not { } linkLocal) + { + output.WriteLine($"[sealed-egress-e2e] skipped ipv6 (IPv6 is disabled on this host, so there is no v6 path to close)"); + return; + } + + (await SettledLinkLocalAsync(setup.ExecPrefix.Concat(["ip", "-6", "-o", "addr", "show", "dev", names.VethNs, "scope", "link"]).ToList())).ShouldNotBeNull("the namespace side's link-local must settle too, or it cannot send"); + + using var listener = new TcpListener(IPAddress.IPv6Any, 0); + listener.Start(); + + var connect = $"import socket\ntry:\n s=socket.create_connection(('{linkLocal}%{names.VethNs}', {((IPEndPoint)listener.LocalEndpoint).Port}), timeout=3); s.close(); print('open')\nexcept OSError as e:\n print(type(e).__name__)"; + var probe = setup.ExecPrefix.Concat(["/usr/bin/python3", "-c", connect]).ToList(); + + var sealedOutcome = (await RunHostAsync(probe)).Trim(); + (await RunHostExitAsync(["nft", "delete", "table", "inet", names.Namespace])).ShouldBe(0, "control setup: the sealed table must be there to delete"); + var controlOutcome = (await RunHostAsync(probe)).Trim(); + + controlOutcome.ShouldBe("open", $"control: with the sealed table gone the same probe must reach the listener at {linkLocal}, or its refusal above proved nothing about the table (got {controlOutcome})"); + sealedOutcome.ShouldNotBe("open", $"the worker's listener must not be reachable over the host veth's IPv6 link-local address {linkLocal} while the sealed table stands"); + + output.WriteLine($"{RanMarker} ipv6 outcome={sealedOutcome} control={controlOutcome}"); + } + finally { await FilteredEgressNetns.TeardownAsync(key, CancellationToken.None); } + } + + [Fact] + public async Task A_30_still_held_by_a_run_that_outlived_its_worker_is_not_handed_to_the_next_run() + { + if (!Seals()) return; + + // A run survives its worker by design, and so do its namespace and veth; the reservation lock does not. A fresh + // worker that trusted the lock alone would hand the survivor's /30 to its next sealed launch, and the kernel + // would split the two runs' broker replies between two veths. Releasing the survivor's reservation without + // tearing its namespace down is exactly what the restart leaves behind. + var survivor = Guid.NewGuid().ToString("N"); + var next = Guid.NewGuid().ToString("N"); + var first = await FilteredEgressNetns.SetupSealedAsync(survivor, brokerPort: 9, timeoutSeconds: 20, CancellationToken.None); + + try + { + first.SetupOk.ShouldBeTrue($"the survivor's sealed namespace must set up on this host: {first.SetupError}"); + EgressSubnetAllocator.Host.Release(survivor); + + var second = await FilteredEgressNetns.SetupSealedAsync(next, brokerPort: 9, timeoutSeconds: 20, CancellationToken.None); + + try + { + second.SetupOk.ShouldBeTrue($"the next run's sealed namespace must set up: {second.SetupError}"); + second.HostIp.ShouldNotBe(first.HostIp, "the survivor's /30 is still on its veth; handing it out again routes one run's replies into the other's namespace"); + + output.WriteLine($"{RanMarker} restart-reissue survivor={first.HostIp} next={second.HostIp}"); + } + finally { await FilteredEgressNetns.TeardownAsync(next, CancellationToken.None); } + } + finally { await FilteredEgressNetns.TeardownAsync(survivor, CancellationToken.None); } + } + + [Fact] + public async Task A_host_whose_policy_rule_discards_the_run_s_replies_fails_the_setup_and_leaks_nothing() + { + if (!Seals()) return; + + // The allocator skips what the host's route listing covers, but a null route in a table that a policy rule + // consults before main wins by rule ORDER, not prefix length: every step of the setup succeeds, and then every + // reply from the broker to the namespace is discarded. The /30 here is from TEST-NET-1 (RFC 5737), which the + // allocator never hands out, and the rule covers only that /30, so it cannot reach another run on this host. + var third = RandomNumberGenerator.GetInt32(0, 64) * 4; + var lease = new EgressSubnetAllocator.Lease { Cidr = $"192.0.2.{third}/30", HostIp = $"192.0.2.{third + 1}", NsIp = $"192.0.2.{third + 2}" }; + var table = RandomNumberGenerator.GetInt32(10_000, 1_000_000).ToString(CultureInfo.InvariantCulture); + var rule = new[] { "pref", "100", "to", lease.Cidr, "lookup", table }; + var runId = Guid.NewGuid().ToString("N"); + var plan = FilteredEgressPlan.BuildSealed(runId, brokerPort: 9, lease); + + // A run of this test killed between its rule add and its cleanup leaves a rule for its /30 in a table this run + // cannot name; left there, it would fail this run's control and blame the check. + for (var stale = 0; stale < 8 && await RunHostExitAsync(["ip", "rule", "del", "pref", "100", "to", lease.Cidr]) == 0; stale++) { } + + try + { + var control = await FilteredEgressNetns.ApplyAsync(runId, plan, timeoutSeconds: 20, CancellationToken.None); + control.SetupOk.ShouldBeTrue($"control: the same plan must set up on a host with no such rule, or the check refuses what it should admit: {control.SetupError}"); + await FilteredEgressNetns.TeardownAsync(runId, CancellationToken.None); + + (await RunHostExitAsync(["ip", "route", "add", "unreachable", "192.0.2.0/24", "table", table])).ShouldBe(0, "setup: the null route — broader than a /30, which the route listing ignores — must be installable in its own table"); + (await RunHostExitAsync(["ip", "rule", "add", .. rule])).ShouldBe(0, "setup: the rule that consults it before main must be installable"); + + var refused = await FilteredEgressNetns.ApplyAsync(runId, plan, timeoutSeconds: 20, CancellationToken.None); + + refused.SetupOk.ShouldBeFalse("a namespace the host can never answer must fail its setup, not admit a run that spends its timeout on a dead broker"); + refused.SetupError.ShouldNotBeNull().ShouldContain(string.Join(' ', plan.RouteCheckArgv), customMessage: "the refusal names the lookup that found it, so an operator can rerun it"); + (await NetnsExistsAsync(plan.Namespace)).ShouldBeFalse("a setup that failed its route check tears its namespace down"); + (await RunHostExitAsync(["ip", "link", "show", plan.VethHost])).ShouldNotBe(0, "and the host end of its veth, with the address on it"); + + output.WriteLine($"{RanMarker} policy-route-discard {refused.SetupError}"); + } + finally + { + await RunHostExitAsync(["ip", "rule", "del", .. rule]); + await RunHostExitAsync(["ip", "route", "flush", "table", table]); + await FilteredEgressNetns.TeardownAsync(runId, CancellationToken.None); + } + } + + public void Dispose() + { + foreach (var dir in _spoolDirs) + { + try { Directory.Delete(dir, recursive: true); } catch { /* best-effort cleanup of a spool dir */ } + } + } + + /// Probes every door from inside the run and prints one JSON object of what each did. It never fails on a shut door — the test decides. + private const string ProbeScript = """ + import json, os, socket, urllib.parse, urllib.request + res = {} + url = os.environ['BROKER_URL'] + req = urllib.request.Request(url + '/v1/messages', data=b'{}', method='POST', headers={'Authorization': 'Bearer ' + os.environ['RUN_TOKEN'], 'content-type': 'application/json'}) + try: + res['broker'] = str(urllib.request.urlopen(req, timeout=10).status) + except Exception as e: + res['broker'] = type(e).__name__ + ':' + str(e) + def tcp(host, port): + try: + s = socket.create_connection((host, port), timeout=3); s.close(); return 'open' + except OSError as e: + return type(e).__name__ + def udp(host, port): + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.settimeout(3) + s.sendto(bytes.fromhex('123401000001000000000000076578616d706c6503636f6d0000010001'), (host, port)); s.recvfrom(512); return 'answered' + except OSError as e: + return type(e).__name__ + res['internet'] = tcp('1.1.1.1', 80) + res['dns_tcp'] = tcp('8.8.8.8', 53) + res['dns_udp'] = udp('8.8.8.8', 53) + res['gateway_other'] = tcp(urllib.parse.urlparse(url).hostname, int(os.environ['OTHER_PORT'])) + res['proxies'] = ','.join(sorted(n for n in os.environ if n.lower() in ('http_proxy', 'https_proxy', 'all_proxy'))) or 'none' + print(json.dumps(res)) + """; + + /// A lane that seals is root with bwrap, ip and nft; there a namespace that cannot be built is a failure, not a skip. + private static bool Seals() + { + if (BubblewrapSandbox.Available is null || !FilteredEgressNetns.IsSupported) return false; + + FilteredEgressNetns.CanSeal.ShouldBeTrue("bwrap, ip and nft are all here, but this process could not build a throwaway namespace — without that privilege every network-off brokered run is severed from its model"); + return true; + } + + private async Task<(SandboxResult Result, Dictionary Probe)> RunDurableAsync(SandboxSpec spec) + { + var key = Guid.NewGuid().ToString("N"); + var runner = new LocalProcessRunner(); + var lines = new List(); + + var handle = await runner.LaunchAsync(spec, key, CancellationToken.None); + _spoolDirs.Add(handle.SpoolDirectory); + + handle.EgressNetnsKey.ShouldBe(key, "a sealed run must launch inside a namespace keyed by the run and recorded on its handle, or no reaper can tear it down"); + var confinement = handle.Confinement.ShouldNotBeNull("the launch must record what confinement it applied"); + confinement.EgressSealedToBroker.ShouldBeTrue("the record must say the run was sealed to its broker, not merely severed"); + confinement.NetworkSevered.ShouldBeTrue("a sealed run is severed from everything else"); + + var result = await runner.AttachAsync(handle, (frame, _) => { lines.Add(frame.Text); return Task.CompletedTask; }, CancellationToken.None); + + (await NetnsExistsAsync(FilteredEgressPlan.NamespaceFor(key))).ShouldBeFalse("the sealed namespace must be torn down on the run's terminal path"); + (await RunHostExitAsync(["nft", "list", "table", "inet", FilteredEgressPlan.NamespaceFor(key)])).ShouldNotBe(0, "and so must its host-side inet table, which deleting the namespace does not remove"); + + return (result, ParseProbe(string.Join('\n', lines))); + } + + private static async Task<(SandboxResult Result, Dictionary Probe)> RunAsync(SandboxSpec spec) + { + var result = await new LocalProcessRunner().RunAsync(spec, CancellationToken.None); + + return (result, ParseProbe(result.Stdout)); + } + + private static Dictionary ParseProbe(string stdout) + { + var line = stdout.Split('\n').Select(l => l.Trim()).LastOrDefault(l => l.StartsWith('{')); + + return line is null ? new Dictionary { ["broker"] = $"no probe output: {stdout}", ["internet"] = "?", ["dns_tcp"] = "?", ["dns_udp"] = "?", ["gateway_other"] = "?" } : JsonSerializer.Deserialize>(line)!; + } + + private static string Describe(Dictionary probe) => string.Join(' ', probe.Select(p => $"{p.Key}={p.Value}")); + + private static ModelCredentialLeaseRequest Lease() => + new() { RunId = Guid.NewGuid(), TeamId = Guid.NewGuid(), Epoch = 1, Upstream = new() { Provider = "Anthropic", ApiKey = "sk-sealed-e2e-upstream" }, Ttl = TimeSpan.FromMinutes(5) }; + + /// The link-local address reports once duplicate-address detection has finished with it (no longer "tentative"), or null where IPv6 is disabled and there is none. Fails if it never settles. + private static async Task SettledLinkLocalAsync(IReadOnlyList listArgv) + { + var watch = Stopwatch.StartNew(); + + while (true) + { + var line = (await RunHostAsync(listArgv)).Split('\n').FirstOrDefault(l => l.Contains(" fe80:", StringComparison.OrdinalIgnoreCase)); + + if (line is null && watch.Elapsed >= TimeSpan.FromSeconds(3)) return null; // no link-local at all: IPv6 is off here + + if (line is not null && !line.Contains("tentative", StringComparison.Ordinal)) + return line.Split(' ', StringSplitOptions.RemoveEmptyEntries).First(t => t.StartsWith("fe80:", StringComparison.OrdinalIgnoreCase)).Split('/')[0]; + + watch.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(15), $"the link-local address never left duplicate-address detection: {line?.Trim()}"); + await Task.Delay(200); + } + } + + private static async Task RunHostExitAsync(IReadOnlyList argv) + { + var psi = new ProcessStartInfo { FileName = argv[0], UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true }; + foreach (var arg in argv.Skip(1)) psi.ArgumentList.Add(arg); + + using var process = Process.Start(psi)!; + await process.WaitForExitAsync(); + + return process.ExitCode; + } + + private static async Task NetnsExistsAsync(string ns) => + (await RunHostAsync(["ip", "netns", "list"])).Split('\n').Any(line => line.Trim().Split(' ').FirstOrDefault() == ns); + + private static async Task RunHostAsync(IReadOnlyList argv) + { + var psi = new ProcessStartInfo { FileName = argv[0], UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true }; + foreach (var arg in argv.Skip(1)) psi.ArgumentList.Add(arg); + + using var process = Process.Start(psi)!; + var stdout = await process.StandardOutput.ReadToEndAsync(); + await process.WaitForExitAsync(); + + return stdout; + } + + /// The provider, answering 200 to anything the broker relays — this lane asserts reachability, never what a model said. + private sealed class AlwaysOkUpstream : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{\"ok\":true}") }); + } +} diff --git a/backend/tests/CodeSpace.UnitTests/Agents/AgentAutonomyPolicyTests.cs b/backend/tests/CodeSpace.UnitTests/Agents/AgentAutonomyPolicyTests.cs index c932a852f..79463cb15 100644 --- a/backend/tests/CodeSpace.UnitTests/Agents/AgentAutonomyPolicyTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Agents/AgentAutonomyPolicyTests.cs @@ -236,6 +236,10 @@ public void DescribeNetwork_discloses_a_host_whose_filtered_egress_subnet_reserv AgentAutonomyPolicy.DescribeNetwork(AgentAutonomyLevel.Standard, AgentAutonomyLevel.Trusted, Unbounded) .ShouldNotContain(AgentAutonomyPolicy.ProcessLocalSubnetCaveat, customMessage: "a severed run reserves no /30 at all, so the sentence must not carry a caveat about one"); + + // A network-off run SEALED to its broker does hold a /30, so it carries the caveat a severed one must not. + AgentAutonomyPolicy.DescribeNetwork(AgentAutonomyLevel.Standard, AgentAutonomyLevel.Trusted, Unbounded, new SandboxConfinement { Outcome = SandboxConfinementOutcome.Confined, NetworkSevered = true, EgressSealedToBroker = true }) + .ShouldBe("Network: off (Standard)" + AgentAutonomyPolicy.SealedToBrokerQualifier + AgentAutonomyPolicy.ProcessLocalSubnetCaveat, "a sealed run's /30 is only unique inside one worker here too"); } AgentAutonomyPolicy.DescribeNetwork(AgentAutonomyLevel.Trusted, AgentAutonomyLevel.Trusted, Unbounded) diff --git a/backend/tests/CodeSpace.UnitTests/Agents/AgentRunExecutorEgressTests.cs b/backend/tests/CodeSpace.UnitTests/Agents/AgentRunExecutorEgressTests.cs index 74b5ed7f0..fe5cc8601 100644 --- a/backend/tests/CodeSpace.UnitTests/Agents/AgentRunExecutorEgressTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Agents/AgentRunExecutorEgressTests.cs @@ -27,6 +27,20 @@ public void Full_egress_leaves_the_spec_unchanged() result.EgressAllowlist.ShouldBeNull(); } + [Theory] + [InlineData(AgentNetworkAccess.Off, 43121, 43121)] // network off + a brokered model → the port a confining runner seals it to + [InlineData(AgentNetworkAccess.Off, null, null)] // network off, unbrokered → nothing to seal to; severed as always + [InlineData(AgentNetworkAccess.On, 43121, null)] // network on reaches its broker already; its egress is the allowlist's business + public void Only_a_network_off_brokered_run_carries_its_broker_port(AgentNetworkAccess network, int? brokerPort, int? expected) + { + var spec = new SandboxSpec { Command = "agent" }; + + var result = AgentRunExecutor.ApplySealedEgress(spec, new AgentPermissions { Network = network }, brokerPort); + + result.ModelBrokerPort.ShouldBe(expected); + if (expected is null) result.ShouldBeSameAs(spec, "nothing to stamp — the spec is returned untouched"); + } + [Fact] public void Allowlist_pins_the_model_and_extra_hosts() { diff --git a/backend/tests/CodeSpace.UnitTests/Sessions/Room/RoomArtifactProducerFoldTests.cs b/backend/tests/CodeSpace.UnitTests/Sessions/Room/RoomArtifactProducerFoldTests.cs index aa52f20d7..0bf4ed9d7 100644 --- a/backend/tests/CodeSpace.UnitTests/Sessions/Room/RoomArtifactProducerFoldTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Sessions/Room/RoomArtifactProducerFoldTests.cs @@ -29,6 +29,15 @@ public class RoomArtifactProducerFoldTests private static readonly Guid Agent = Guid.NewGuid(); + [Fact] + public void A_producer_sealed_to_its_broker_is_named_sealed_never_severed() + { + // A sealed run kept one route; calling it severed on its card is the stronger claim, and contradicts the + // turn's own sentence for the same record. + RoomProjector.PostureOf(new SandboxConfinement { Outcome = SandboxConfinementOutcome.Confined, NetworkSevered = true, EgressSealedToBroker = true }) + .ShouldBe(RoomConfinementPosture.ConfinedEgressSealedToBroker); + } + [Theory] [InlineData(SandboxConfinementOutcome.Confined, true, RoomConfinementPosture.ConfinedNetworkSevered)] [InlineData(SandboxConfinementOutcome.Confined, false, RoomConfinementPosture.Confined)] diff --git a/backend/tests/CodeSpace.UnitTests/Sessions/Room/RoomConfinementReductionTests.cs b/backend/tests/CodeSpace.UnitTests/Sessions/Room/RoomConfinementReductionTests.cs index 48cf498c5..cc8b8698a 100644 --- a/backend/tests/CodeSpace.UnitTests/Sessions/Room/RoomConfinementReductionTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Sessions/Room/RoomConfinementReductionTests.cs @@ -52,6 +52,20 @@ public void A_confined_but_unsevered_agent_outranks_an_unconfined_one_and_loses_ .Outcome.ShouldBe(SandboxConfinementOutcome.NotApplicable, "a runner that confines nothing is weaker than one that confines without severing"); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public void A_sealed_agent_outranks_a_severed_one_whatever_order_they_arrive_in(bool sealedFirst) + { + // A sealed agent kept a route to its broker, so a turn holding one is only as severed as THAT agent — and the + // rows arrive unordered, so the pick must not depend on which one came first. + var sealedRecord = JsonSerializer.Serialize(new SandboxConfinement { Outcome = SandboxConfinementOutcome.Confined, NetworkSevered = true, EgressSealedToBroker = true }, AgentJson.Options); + var severed = Json(SandboxConfinementOutcome.Confined, severed: true); + + RoomProjector.LeastConfined(sealedFirst ? new[] { sealedRecord, severed } : new[] { severed, sealedRecord })! + .EgressSealedToBroker.ShouldBeTrue("the weaker of the two decides the turn's sentence"); + } + [Theory] // An unconfined agent vs a runner that confines nothing — the rank ties them at "yes, something here could reach // the network", but the two print DIFFERENT causes, so the tie must not be broken by arrival order. diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/BubblewrapSandboxTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/BubblewrapSandboxTests.cs index 1b8e134c3..014c1638e 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/BubblewrapSandboxTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/BubblewrapSandboxTests.cs @@ -44,6 +44,21 @@ public void DeriveConfinement_records_what_the_launch_actually_did(string? avail customMessage: "NetworkSevered must mirror the SAME ShareNetwork input BuildArgs turns into --unshare-net — a record that can outrun the argv is worse than no record"); } + [Theory] + [InlineData("/usr/bin/bwrap", true, SandboxConfinementOutcome.Confined, true, true)] // a sealed namespace IS severed, and says it kept its broker + [InlineData("/usr/bin/bwrap", false, SandboxConfinementOutcome.Confined, false, false)] // a plain shared network is neither + [InlineData(null, true, SandboxConfinementOutcome.Unconfined, false, false)] // an unconfined run claims nothing, sealed or not + public void A_run_sealed_to_its_broker_is_recorded_severed_and_sealed(string? available, bool sealedToBroker, SandboxConfinementOutcome expectedOutcome, bool expectedSevered, bool expectedSealed) + { + // Inside the sealed namespace bwrap SHARES the network (it must not unshare the namespace it was placed in), so + // a record read off ShareNetwork alone would call a sealed run "egress NOT severed" — the flag is what says it. + var confinement = BubblewrapSandbox.DeriveConfinement(available, SandboxConfinement.ReasonNoBubblewrap, shareNetwork: true, egressAllowlist: null, sealedToBroker); + + confinement.Outcome.ShouldBe(expectedOutcome); + confinement.NetworkSevered.ShouldBe(expectedSevered); + confinement.EgressSealedToBroker.ShouldBe(expectedSealed); + } + [Theory] // No allowlist — the two plain arms: shared host network, and the tier's Off. [InlineData(true, null)] diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/CapabilityProbeTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/CapabilityProbeTests.cs new file mode 100644 index 000000000..d3404bec3 --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Workflows/CapabilityProbeTests.cs @@ -0,0 +1,103 @@ +using CodeSpace.Core.Services.Agents.Sandbox.Isolation; +using Shouldly; + +namespace CodeSpace.UnitTests.Workflows; + +/// +/// Pins how a worker keeps its answer to a capability it can only settle by trying ("can I seal?"): a proof for good, a failure for one retry interval on a +/// monotonic clock and then probed again, its reason kept — and a re-probe that never makes other launches queue. +/// Caching a transient failure for the process lifetime severed every later network-off brokered run on the worker. +/// +[Trait("Category", "Unit")] +public class CapabilityProbeTests +{ + private static readonly TimeSpan Interval = TimeSpan.FromMinutes(1); + + [Fact] + public void A_failure_stands_for_one_interval_then_is_probed_again_and_a_proof_is_kept() + { + var now = TimeSpan.Zero; + var answers = new Queue(new[] { "ip netns add → exit 1: busy", null }); + var probes = 0; + var cache = new CapabilityProbe(() => { probes++; return answers.Dequeue(); }, () => now, Interval); + + cache.Holds.ShouldBeFalse(); + cache.UnavailableReason.ShouldBe("ip netns add → exit 1: busy", "the failed step is kept for whoever reports the refusal"); + + now += Interval - TimeSpan.FromSeconds(1); + cache.Holds.ShouldBeFalse(); + probes.ShouldBe(1, "a failure stands until its interval has passed"); + + now += TimeSpan.FromSeconds(1); + cache.Holds.ShouldBeTrue("a transient failure must not outlive its interval"); + cache.UnavailableReason.ShouldBeNull(); + + now += Interval * 10; + cache.Holds.ShouldBeTrue(); + probes.ShouldBe(2, "a proof holds for the process — it is never probed again"); + } + + [Fact] + public void A_failed_re_probe_starts_a_new_interval_rather_than_re_probing_on_every_call() + { + var now = TimeSpan.Zero; + var probes = 0; + var cache = new CapabilityProbe(() => { probes++; return "still failing"; }, () => now, Interval); + + cache.Holds.ShouldBeFalse(); + now += Interval; + cache.Holds.ShouldBeFalse(); + cache.Holds.ShouldBeFalse(); + now += Interval - TimeSpan.FromSeconds(1); + cache.Holds.ShouldBeFalse(); + + probes.ShouldBe(2, "a host that keeps failing is probed once per interval, not once per launch"); + } + + [Fact] + public async Task Concurrent_first_callers_wait_for_the_first_probe_instead_of_being_refused() + { + // A fresh worker that may well seal must not refuse the launches that arrive while it finds out. + using var started = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + var cache = new CapabilityProbe(() => { started.Set(); release.Wait(TimeSpan.FromSeconds(10)); return null; }, () => TimeSpan.Zero, Interval); + + var first = Task.Run(() => cache.Holds); + started.Wait(TimeSpan.FromSeconds(10)).ShouldBeTrue("fixture: the first probe started"); + var second = Task.Run(() => cache.Holds); + + (await Task.WhenAny(second, Task.Delay(300))).ShouldNotBe(second, "a caller arriving during the FIRST probe waits for its answer"); + + release.Set(); + (await first).ShouldBeTrue(); + (await second).ShouldBeTrue("and gets the probe's answer, not a refusal"); + } + + [Fact] + public async Task A_re_probe_in_flight_answers_other_callers_with_the_standing_failure_instead_of_blocking_them() + { + var now = TimeSpan.Zero; + using var reProbing = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + var calls = 0; + var cache = new CapabilityProbe(() => + { + if (Interlocked.Increment(ref calls) == 1) return "first probe failed"; + reProbing.Set(); + release.Wait(TimeSpan.FromSeconds(10)); + return null; + }, () => now, Interval); + + cache.Holds.ShouldBeFalse(); + now += Interval; + + var reProbe = Task.Run(() => cache.Holds); + reProbing.Wait(TimeSpan.FromSeconds(10)).ShouldBeTrue("fixture: the re-probe started"); + + cache.Holds.ShouldBeFalse("a caller arriving mid-re-probe takes the standing answer at once rather than queueing behind it"); + + release.Set(); + (await reProbe).ShouldBeTrue(); + cache.Holds.ShouldBeTrue(); + } +} diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/EgressSubnetAllocatorTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/EgressSubnetAllocatorTests.cs index e4f007e9e..aa72b63c7 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/EgressSubnetAllocatorTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/EgressSubnetAllocatorTests.cs @@ -45,6 +45,112 @@ public void A_lease_is_a_well_formed_30_with_consecutive_host_and_ns_addresses() lease.NsIp.ShouldBe(lease.Cidr.Replace($".{baseOctet}/30", $".{baseOctet + 2}"), "ns = block base + 2"); } + [Fact] + public void A_30_the_host_already_routes_is_never_handed_out() + { + // The lock proves no live WORKER holds a /30; it cannot see the host's own network or a run whose namespace + // outlived the worker that reserved it (its veth keeps the address, the lock died with the process). Here the + // host routes the first candidate as a surviving run's /30 and the second inside its own /31 of a LAN. + var routes = HostRoutedPrefixes.Parse("""[{"dst":"default","gateway":"172.17.0.1"},{"dst":"10.1.1.0/30","dev":"csh-survivor"},{"type":"local","dst":"10.1.1.5","dev":"eth9"}]"""); + + var lease = NewWorker().Acquire(Guid.NewGuid().ToString("N"), routes); + + lease.Cidr.ShouldBe("10.1.1.8/30", "the surviving run's 10.1.1.0/30 and the /30 holding the host's own 10.1.1.5 are both skipped; the default route is no use of any one /30"); + } + + [Fact] + public void A_broad_route_over_the_first_range_moves_the_walk_on_instead_of_refusing() + { + // A worker in a 10.1.0.0/16 LAN routes every /30 the walk used to consider. Those must not count against the + // concurrency bound: 10.2.0.0/16 and beyond are free, and a launch refused there is a refusal nothing forced. + var routes = HostRoutedPrefixes.Parse("""[{"dst":"10.1.0.0/16","dev":"eth0"}]"""); + + var lease = NewWorker().Acquire(Guid.NewGuid().ToString("N"), routes); + + lease.Cidr.ShouldBe("10.2.1.0/30"); + } + + [Fact] + public void The_walks_jump_inverts_the_candidate_order_at_every_octet_boundary() + { + // The walk moves past a routed range by computing the first candidate above it, so that computation must be + // the exact inverse of the order candidates are named in — a drift would skip free /30s or revisit routed ones. + // Sampled across every boundary CidrAt has: each /30 block, each third-octet wrap, each second-octet wrap. + var samples = new[] { 0, 1, 62, 63, 64, 65, 16255, 16256, 16257, 2_000_003, EgressSubnetAllocator.CandidateCount - 1 }; + + foreach (var index in samples) + { + var network = Address(EgressSubnetAllocator.CidrAt(index).Split('/')[0]); + + EgressSubnetAllocator.IndexAtOrAbove(network).ShouldBe(index, $"candidate {index} ({EgressSubnetAllocator.CidrAt(index)}) is its own first candidate"); + EgressSubnetAllocator.IndexAtOrAbove(network + 1).ShouldBe(index + 1, $"one address past candidate {index} is the next candidate"); + } + + EgressSubnetAllocator.IndexAtOrAbove(Address("10.254.254.253")).ShouldBe(EgressSubnetAllocator.CandidateCount, "nothing lies above the last candidate"); + } + + [Theory] + [InlineData("""[{"dst":"10.0.0.0/9","dev":"eth0"}]""", "10.128.1.0/30")] // a broad route over the lower half of the space + [InlineData("""[{"type":"blackhole","dst":"10.0.0.0/8"}]""", "10.1.1.0/30")] // a null route discards traffic; it has no peers to shadow + [InlineData("""[{"type":"unreachable","dst":"10.1.1.0/24"}]""", "10.1.1.0/30")] + [InlineData("""[{"type":"unreachable","dst":"10.1.1.2"}]""", "10.1.1.4/30")] // a banned /32 wins longest-prefix match over the run's /30 — it still occupies + [InlineData("""[{"type":"blackhole","dst":"10.1.1.0/30"}]""", "10.1.1.4/30")] + public void The_walk_passes_routed_ranges_whole_and_ignores_null_routes(string routesJson, string expected) => + NewWorker().Acquire(Guid.NewGuid().ToString("N"), HostRoutedPrefixes.Parse(routesJson)).Cidr.ShouldBe(expected); + + [Fact] + public void A_host_that_routes_everything_this_worker_does_not_hold_blames_the_routes_not_4096_live_runs() + { + // A worker holding one live /30 gains a route over all of 10/8 (a VPN connecting). The walk finds its own /30 + // and nothing else it may try; the wall is the routes, and the message must say so rather than claim 4096. + // The live /30 sits past the first candidate, so the walk jumps over it — the count must still include it. + var allocator = NewWorker(); + var live = allocator.Acquire(Guid.NewGuid().ToString("N"), HostRoutedPrefixes.Parse("""[{"dst":"10.1.1.0/30"}]""")); + + var refusal = Should.Throw(() => allocator.Acquire(Guid.NewGuid().ToString("N"), HostRoutedPrefixes.Parse("""[{"dst":"10.0.0.0/8"}]"""))); + + refusal.Message.ShouldContain("does not already route"); + refusal.Message.ShouldContain("this worker holds 1"); + } + + [Fact] + public void Running_out_of_30s_is_a_failed_setup_the_caller_can_type_not_an_exception_that_escapes_it() + { + // A sealed run types its setup failures as sandbox_sealed_egress_unavailable; an exhaustion thrown past the + // setup's own result would land as a bare executor-error the supervisor cannot steer on. + var (subnet, exhausted) = FilteredEgressNetns.Reserve(NewWorker(), Guid.NewGuid().ToString("N"), HostRoutedPrefixes.Parse("""[{"dst":"10.0.0.0/8"}]""")); + + subnet.ShouldBeNull(); + exhausted.ShouldNotBeNull().ShouldContain("does not already route", customMessage: "the setup failure carries the allocator's own account of why"); + } + + private static ulong Address(string ip) => System.Net.IPAddress.Parse(ip).GetAddressBytes().Aggregate(0UL, (value, octet) => value << 8 | octet); + + [Theory] + [InlineData("0.0.0.0/1")] // a VPN's default override — a default in all but name + [InlineData("128.0.0.0/1")] + public void A_route_broader_than_a_slash_8_is_a_default_not_a_network(string routed) => + HostRoutedPrefixes.Parse($$"""[{"dst":"{{routed}}"}]""").Overlaps("10.1.1.0/30").ShouldBeFalse(); + + [Fact] + public void A_host_that_routes_every_candidate_says_so_instead_of_blaming_4096_live_runs() + { + var routes = HostRoutedPrefixes.Parse("""[{"dst":"10.0.0.0/8","dev":"vpn0"}]"""); + + var refusal = Should.Throw(() => NewWorker().Acquire(Guid.NewGuid().ToString("N"), routes)); + + refusal.Message.ShouldContain("does not already route", customMessage: "the wall is the host's own routes, not concurrency"); + } + + [Theory] + [InlineData("10.1.1.0/30", "10.1.1.0/30", true)] // the same /30 — a survivor's + [InlineData("10.1.0.0/16", "10.1.1.4/30", true)] // a LAN containing it + [InlineData("10.1.1.6", "10.1.1.4/30", true)] // a single address inside it (no length ⇒ /32) + [InlineData("10.1.1.8/30", "10.1.1.4/30", false)] // the next /30 over + [InlineData("192.168.0.0/16", "10.1.1.4/30", false)] + public void A_candidate_overlaps_any_prefix_sharing_an_address_with_it(string routed, string candidate, bool expected) => + HostRoutedPrefixes.Parse($$"""[{"dst":"{{routed}}"}]""").Overlaps(candidate).ShouldBe(expected); + [Fact] public void Concurrent_runs_never_share_a_subnet_even_when_many_are_active() { @@ -245,9 +351,14 @@ public void A_reservation_file_this_worker_cannot_open_is_SKIPPED_not_read_as_a_ } [Theory] - [InlineData(true)] // EACCES → UnauthorizedAccessException - [InlineData(false)] // EROFS → IOException (a remount-ro is NOT a rights error, and reading only for the rights error left the misreport reachable) - public void A_directory_that_turns_unwritable_after_the_probe_names_the_MOUNT_not_4096_live_runs(bool rightsError) + // EACCES → UnauthorizedAccessException; EROFS → IOException (a remount-ro is NOT a rights error, and reading only + // for the rights error left the misreport reachable). Each with and without the routes production always passes: + // a worker in 10.1.1.0/24 skips those /30s as routed, and must still be told it is the mount, not its routes. + [InlineData(true, false)] + [InlineData(true, true)] + [InlineData(false, false)] + [InlineData(false, true)] + public void A_directory_that_turns_unwritable_after_the_probe_names_the_MOUNT_not_4096_live_runs(bool rightsError, bool lanRoutes) { // Every candidate lost the same way can be 4096 live /30s — or a mount that went read-only after the probe. // Indistinguishable from inside the loop, and guessing "contention" reported "this host already holds 4096 @@ -256,7 +367,9 @@ public void A_directory_that_turns_unwritable_after_the_probe_names_the_MOUNT_no // that proves — for either errno, since a remount-ro arrives as EROFS rather than EACCES. var allocator = new EgressSubnetAllocator(_reservations, new OpenerThatBreaksAfterTheProbe(rightsError).Open, NeverAskedForAChild); - var refusal = Should.Throw(() => allocator.Acquire(Guid.NewGuid().ToString("N"))); + var routes = lanRoutes ? HostRoutedPrefixes.Parse("""[{"dst":"10.1.1.0/24","dev":"eth0"},{"type":"local","dst":"10.1.1.20","dev":"eth0"}]""") : null; + + var refusal = Should.Throw(() => allocator.Acquire(Guid.NewGuid().ToString("N"), routes)); refusal.Reason.ShouldBe("the reservation directory cannot be written", "the cause named must be the mount, not imaginary contention"); refusal.ReservationDirectory.ShouldBe(_reservations, "the actionable fact is WHICH directory an operator has to fix"); diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/FilteredEgressPlanTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/FilteredEgressPlanTests.cs index 4cbbf81cb..87bc9f260 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/FilteredEgressPlanTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/FilteredEgressPlanTests.cs @@ -26,6 +26,47 @@ public void Names_are_run_unique_and_consistent_across_the_plan() a.ExecPrefix.ShouldBe(new[] { "ip", "netns", "exec", a.Namespace }, "the command runs inside this run's netns"); a.TeardownCommands.ShouldContain(c => c.SequenceEqual(new[] { "ip", "netns", "del", a.Namespace }), "teardown deletes the netns"); a.TeardownCommands.ShouldContain(c => c.SequenceEqual(new[] { "nft", "delete", "table", "ip", a.Namespace }), "teardown deletes the nft table"); + a.TeardownCommands.ShouldContain(c => c.SequenceEqual(new[] { "nft", "delete", "table", "inet", a.Namespace }), "teardown deletes a sealed run's inet table too — every reaper knows only the run id"); + } + + [Fact] + public void A_sealed_plan_has_no_route_no_forwarding_and_no_nat() + { + var plan = FilteredEgressPlan.BuildSealed("run-5ea1ed01", 43121, Subnet); + + plan.SetupCommands.ShouldNotContain(c => c.Contains("route"), "no default route: a packet to anywhere but the /30 fails with ENETUNREACH at once"); + plan.SetupCommands.ShouldNotContain(c => c[0] == "sysctl", "nothing is forwarded, so the host's forwarding switch is left alone"); + plan.SetupCommands.ShouldContain(c => c.SequenceEqual(new[] { "ip", "netns", "exec", plan.Namespace, "ip", "addr", "add", $"{Subnet.NsIp}/30", "dev", plan.VethNs }), "the namespace still holds its /30, so the gateway is on-link"); + plan.HostIp.ShouldBe(Subnet.HostIp, "the gateway the child reaches its broker at"); + plan.ExecPrefix.ShouldBe(new[] { "ip", "netns", "exec", plan.Namespace }); + plan.TeardownCommands.Select(c => string.Join(' ', c)).ShouldBe(FilteredEgressPlan.TeardownCommandsFor("run-5ea1ed01").Select(c => string.Join(' ', c)), "a sealed namespace is torn down by the same run-id-only commands every reaper already runs"); + } + + [Fact] + public void A_sealed_ruleset_admits_only_the_broker_port_on_the_gateway() + { + // Pinned whole, because every line is load-bearing and a membership assertion is satisfied by the wrong chain: + // inet (the veth's IPv6 link-local must be covered by the same drop), replace-not-append (a revise round reuses + // the name), an INPUT filter admitting only the broker's port on the gateway (the worker's own listeners and + // every other run's broker are reached through that hook), and a FORWARD drop. No DNS, no NAT, and keyed on the + // veth — never a subnet a degraded allocator might hand another run too. + var plan = FilteredEgressPlan.BuildSealed("run-5ea1ed02", 43121, Subnet); + + plan.NftRuleset.ShouldBe( + $"table inet {plan.Namespace} {{}}\n" + + $"delete table inet {plan.Namespace}\n" + + $"table inet {plan.Namespace} {{\n" + + " chain input {\n" + + " type filter hook input priority 0;\n" + + $" iifname \"{plan.VethHost}\" ct state established,related accept\n" + + $" iifname \"{plan.VethHost}\" ip daddr {Subnet.HostIp} tcp dport 43121 accept\n" + + $" iifname \"{plan.VethHost}\" drop\n" + + " }\n" + + " chain forward {\n" + + " type filter hook forward priority 0;\n" + + $" iifname \"{plan.VethHost}\" drop\n" + + " }\n" + + "}\n"); } [Fact] @@ -84,4 +125,37 @@ public void Setup_creates_the_netns_and_veth_and_default_route() plan.SetupCommands.ShouldContain(c => c.SequenceEqual(new[] { "sysctl", "-w", "net.ipv4.ip_forward=1" }), "forwarding is enabled so the host NATs the netns out"); plan.NftApplyArgv.ShouldBe(new[] { "nft", "-f", "-" }, "the ruleset is applied on stdin"); } + + [Fact] + public void The_route_check_asks_the_kernel_how_the_host_reaches_the_namespace_end() + { + var plan = FilteredEgressPlan.BuildSealed("run-ffff6666", 43121, Subnet); + + plan.RouteCheckArgv.ShouldBe(new[] { "ip", "route", "get", "10.5.7.18", "from", "10.5.7.17" }, "the lookup a reply from the broker makes: to the namespace's end, from the gateway — as text, which every iproute2 prints"); + } + + [Theory] + [InlineData(0, "10.5.7.18 from 10.5.7.17 dev csh-runffff6 uid 0 \n cache \n", null)] // through the run's own veth + [InlineData(0, "10.5.7.18 from 10.5.7.17 via 192.168.1.1 dev eth0 uid 0 \n cache \n", "through something other than the run's own veth csh-runffff6")] + [InlineData(0, "local 10.5.7.18 from 10.5.7.17 dev lo table local src 10.5.7.18 uid 0 \n cache \n", "through something other than the run's own veth")] // a host address shadows the namespace's end + [InlineData(2, "RTNETLINK answers: Invalid argument\n", "exit 2: RTNETLINK answers: Invalid argument — this host cannot route replies back to 10.5.7.16/30")] // a blackhole a policy rule consults before main + [InlineData(2, "RTNETLINK answers: No route to host", "exit 2: RTNETLINK answers: No route to host")] // unreachable + [InlineData(0, "", "through something other than the run's own veth")] // no answer at all is no proof + [InlineData(0, "10.5.7.18 from 10.5.7.17 uid 0", "through something other than the run's own veth")] // no device named + [InlineData(0, "10.5.7.18 from 10.5.7.17 dev", "through something other than the run's own veth")] + [InlineData(0, "10.5.7.18 dev csh-runffff6 \n10.5.7.18 dev eth0", "through something other than the run's own veth")] // two answers are not one route + [InlineData(0, """[{"dst":"10.5.7.18","from":"10.5.7.17","dev":"csh-runffff6","uid":0,"flags":[],"cache":[]}]""", "through something other than the run's own veth")] // JSON is not what the argv asks for + public void Only_a_route_through_the_run_s_own_veth_passes_the_check(int exit, string output, string? failure) + { + // The /30 was chosen from the routes the host lists, but a policy rule consults its tables in rule order, not + // by prefix length: a blackhole 10.0.0.0/8 in a table checked before main discards the broker's replies to a + // cleanly set-up namespace. Only the kernel's own lookup sees that, so anything but a route through the run's + // own veth fails the setup instead of admitting a run that can never be answered. + var plan = FilteredEgressPlan.BuildSealed("run-ffff6666", 43121, Subnet); + + var reason = plan.RouteCheckFailure(exit, output); + + if (failure is null) reason.ShouldBeNull(); + else reason.ShouldNotBeNull().ShouldContain(failure); + } } diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/LocalProcessDurableRunnerTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/LocalProcessDurableRunnerTests.cs index 7d3e88d5c..437223853 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/LocalProcessDurableRunnerTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/LocalProcessDurableRunnerTests.cs @@ -767,6 +767,22 @@ public void A_durable_codex_launch_stands_its_sandbox_down_exactly_where_this_ho argv.Contains("read-only").ShouldBe(!confines, "and Codex keeps its own read-only sandbox wherever ours is not there to replace it"); } + [Theory] + [InlineData(false, true, false)] // network off inside a namespace: the sealed launch — its proxy is unreachable, so it goes + [InlineData(false, false, true)] // network off with no namespace: severed, nothing to route — left as it always was + [InlineData(true, true, true)] // network on inside a namespace: an allowlist run, which may well reach its proxy + public void A_sealed_launch_carries_no_proxy_its_namespace_cannot_reach(bool allowNetwork, bool inNamespace, bool keepsProxy) + { + var spec = new SandboxSpec { Command = "agent", AllowNetwork = allowNetwork, Environment = new Dictionary { ["HTTPS_PROXY"] = "http://proxy.corp:3128", ["http_proxy"] = "http://proxy.corp:3128", ["NO_PROXY"] = "localhost" } }; + var prefix = inNamespace ? new[] { "ip", "netns", "exec", "cs-egr-deadbeef" } : Array.Empty(); + + var info = LocalProcessRunner.BuildDurableStartInfo(spec, TempDir(), prefix); + + info.Environment.ContainsKey("HTTPS_PROXY").ShouldBe(keepsProxy, "a sealed child's one destination is its broker on the gateway; a CLI honouring a proxy would send every model call where the namespace cannot reach"); + info.Environment.ContainsKey("http_proxy").ShouldBe(keepsProxy, "both spellings"); + info.Environment.ContainsKey("NO_PROXY").ShouldBeTrue("NO_PROXY is harmless and left alone"); + } + private static readonly IReadOnlyList CodexArgs = new[] { "exec", "--json", "--model", "gpt-5.4", "--sandbox", "read-only", "-c", "otel.exporter=none", "-" }; [Fact] diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/LocalProcessRunnerEnvScrubTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/LocalProcessRunnerEnvScrubTests.cs index c97019090..52cb8a864 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/LocalProcessRunnerEnvScrubTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/LocalProcessRunnerEnvScrubTests.cs @@ -315,6 +315,69 @@ public void The_model_broker_host_token_is_resolved_to_the_address_this_child_ca customMessage: "a harness whose CLI ignores its base-URL env var carries that URL on the ARGV, and the token has to be resolved there too or the brokered run cannot reach the broker at all"); } + [Theory] + [InlineData("10.63.12.1", "localhost,127.0.0.1", null, "localhost,127.0.0.1,10.63.12.1", "localhost,127.0.0.1,10.63.12.1")] // a netns run: its gateway joins what is already exempt + [InlineData(null, "localhost", null, "localhost,127.0.0.1", "localhost,127.0.0.1")] // a loopback broker a NO_PROXY happens to omit + [InlineData(null, "localhost,127.0.0.1", null, "localhost,127.0.0.1", "localhost,127.0.0.1")] // already exempt: left as it was + [InlineData("10.63.12.1", ".corp.internal", null, ".corp.internal,10.63.12.1", ".corp.internal,10.63.12.1")] // only one spelling set: BOTH carry the operator's entries + [InlineData("10.63.12.1", null, "gitlab.corp", "gitlab.corp,10.63.12.1", "gitlab.corp,10.63.12.1")] + [InlineData("10.63.12.1", "a.corp", "b.corp", "a.corp,b.corp,10.63.12.1", "a.corp,b.corp,10.63.12.1")] // both set: their union, in both + [InlineData("10.63.12.1", "*", null, "*,10.63.12.1", "*")] // bypass-everything: the uppercase readers still need the IP named + [InlineData(null, "a.corp", "*", "a.corp,*,127.0.0.1", "*")] + public void A_brokered_child_is_told_not_to_proxy_its_broker(string? gatewayIp, string? upper, string? lower, string expectedUpper, string expectedLower) + { + // The broker's address is one no operator's NO_PROXY can name ahead of time — a per-run gateway — so a CLI + // honouring the worker's proxy sent every model call to the proxy instead. Readers prefer different spellings, + // and a spelling created with the broker alone hid the operator's own entries. A `*` is the one entry the two + // families read differently: Codex's reqwest takes it as an entry that matches no IP address (its brokered + // calls went to the proxy with NO_PROXY=* alone), while curl, Python and undici honour it only as the whole value. + var environment = new Dictionary { ["ANTHROPIC_BASE_URL"] = $"http://{SandboxSpec.ModelBrokerHostToken}:41234/r0uteId", ["HTTPS_PROXY"] = "http://proxy.corp:3128" }; + if (upper is not null) environment["NO_PROXY"] = upper; + if (lower is not null) environment["no_proxy"] = lower; + + var resolved = WithWorkerProxyEnvironment(new Dictionary(), () => LocalProcessRunner.ResolveModelBrokerHost(EnvSpec() with { Environment = environment }, gatewayIp)); + + resolved.Environment["NO_PROXY"].ShouldBe(expectedUpper); + resolved.Environment["no_proxy"].ShouldBe(expectedLower); + } + + [Theory] + [InlineData(null)] + [InlineData("ALL_PROXY")] // the worker's own ALL_PROXY: the scrub drops it, so the child has no proxy to be exempted from + [InlineData("all_proxy")] + public void A_brokered_child_with_no_proxy_keeps_its_environment(string? workerOnly) + { + // A NO_PROXY written into a child with no proxy exempts nothing, and on a macOS or Windows worker it turns off + // Python's lookup of the OS proxy — urllib reads the environment instead once any *_proxy variable is set. + var environment = new Dictionary { ["ANTHROPIC_BASE_URL"] = $"http://{SandboxSpec.ModelBrokerHostToken}:41234/r0uteId" }; + var worker = workerOnly is null ? new Dictionary() : new Dictionary { [workerOnly] = "socks5h://proxy.corp:1080" }; + + var resolved = WithWorkerProxyEnvironment(worker, () => LocalProcessRunner.ResolveModelBrokerHost(EnvSpec() with { Environment = environment }, "10.63.12.1")); + + resolved.Environment.Keys.ShouldBe(new[] { "ANTHROPIC_BASE_URL" }, customMessage: "only the broker host is substituted when the child has no proxy to exempt it from"); + } + + [Fact] + public void A_proxy_the_worker_passes_through_the_scrub_is_one_the_child_is_exempted_from() + { + var environment = new Dictionary { ["ANTHROPIC_BASE_URL"] = $"http://{SandboxSpec.ModelBrokerHostToken}:41234/r0uteId" }; + + var resolved = WithWorkerProxyEnvironment(new Dictionary { ["https_proxy"] = "http://proxy.corp:3128" }, () => LocalProcessRunner.ResolveModelBrokerHost(EnvSpec() with { Environment = environment }, "10.63.12.1")); + + resolved.Environment["NO_PROXY"].ShouldBe("10.63.12.1", "the worker's https_proxy survives the scrub and reaches the child, so the broker must be exempted from it"); + } + + /// Run with every proxy variable of this process cleared but : the worker's own values are the fallback, and this host's must not leak in. + private static SandboxSpec WithWorkerProxyEnvironment(IReadOnlyDictionary worker, Func resolve) + { + var names = new[] { "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy" }; + var prior = names.ToDictionary(name => name, Environment.GetEnvironmentVariable); + + foreach (var name in names) Environment.SetEnvironmentVariable(name, worker.GetValueOrDefault(name)); + try { return resolve(); } + finally { foreach (var (name, value) in prior) Environment.SetEnvironmentVariable(name, value); } + } + [Fact] public void A_command_carrying_the_broker_host_token_is_resolved_too() { diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/NativeLaunchRegistryTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/NativeLaunchRegistryTests.cs index cb507c328..2b67d4108 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/NativeLaunchRegistryTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/NativeLaunchRegistryTests.cs @@ -34,6 +34,127 @@ public void Canonical_binding_preserves_exact_argv_and_all_server_only_fields() NativeLaunchProtocol.SpecHash(frozen with { Environment = environment }).ShouldNotBe(hash); } + [Theory] + [InlineData("rejected", true, true)] // a verdict that nothing was released + [InlineData("committed", false, true)] // the broker died before any verdict — the ready receipt comes before release, so nothing ran + [InlineData("committed", true, false)] // a live broker still deciding, and the patience ran out — proves nothing + [InlineData("indeterminate", false, false)] // may have run — keep its isolation + [InlineData("ready", false, false)] + [InlineData(null, false, false)] // no receipt within the patience — cannot prove anything + public async Task Only_proof_that_nothing_was_released_lets_a_failed_launch_tear_down_its_isolation(string? state, bool brokerAlive, bool expected) + { + // A failed launch that tore down a namespace its agent is still using would cut a live run off from its model; + // one that never tears down a rejected slot leaks a namespace, a host nft table and a /30 nothing else reaps. + var directory = Directory.CreateTempSubdirectory("cs-rejected-").FullName; + try + { + if (state is not null) + await PublishReceiptAsync(directory, state, brokerAlive ? NativeProcess.Current : DeadBroker); + + (await LocalProcessRunner.LaunchProvedUnexecutedAsync(directory)).ShouldBe(expected); + } + finally { Directory.Delete(directory, recursive: true); } + } + + [Fact] + public async Task A_receipt_still_committed_is_waited_for_until_the_bootstrap_rejects_it() + { + // The bootstrap writes "committed" first and replaces it with its verdict as it exits — which can land just + // after the launch that failed here asks. Reading a live broker's "committed" as final would leak the slot. + var directory = Directory.CreateTempSubdirectory("cs-rejected-late-").FullName; + try + { + await PublishReceiptAsync(directory, "committed", NativeProcess.Current); + + var asked = LocalProcessRunner.LaunchProvedUnexecutedAsync(directory); + await Task.Delay(200); + await PublishReceiptAsync(directory, "rejected", NativeProcess.Current); + + (await asked).ShouldBeTrue("a verdict that lands inside the patience is the verdict"); + } + finally { Directory.Delete(directory, recursive: true); } + } + + [Theory] + [InlineData("ready")] // it wrote ready, released, then died + [InlineData("indeterminate")] // it failed after the release + public async Task A_dead_broker_proves_nothing_once_its_receipt_moved_past_committed(string state) + { + // The launch read "committed" and then asks whether the broker died. Between those two reads the broker can + // write "ready", release the agent and die, so its death alone proves nothing: only a receipt read AFTER the + // death, still "committed", is its last word. The file below is that later read; a check that stopped at the + // death would tear the namespace and cgroup out from under a released agent. + var directory = Directory.CreateTempSubdirectory("cs-broker-death-").FullName; + try + { + await PublishReceiptAsync(directory, state, DeadBroker); + + LocalProcessRunner.BrokerDiedBeforeItsVerdict(directory, DeadBroker).ShouldBeFalse($"a receipt at '{state}' after the broker died means it got past its verdict"); + } + finally { Directory.Delete(directory, recursive: true); } + } + + [Fact] + public async Task The_receipt_is_read_after_the_broker_is_known_dead_not_before() + { + // The window the order closes: the receipt still reads "committed", then — while liveness is being sampled — + // the broker writes "ready", releases the agent and dies. A check that read the receipt first would hold that + // stale "committed" and a death, and tear the isolation out from under a released agent. + var directory = Directory.CreateTempSubdirectory("cs-broker-order-").FullName; + try + { + await PublishReceiptAsync(directory, "committed", DeadBroker); + + bool ReleasedThenDied(NativeProcessIdentity _) + { + PublishReceipt(directory, "ready", DeadBroker); + return false; + } + + LocalProcessRunner.BrokerDiedBeforeItsVerdict(directory, DeadBroker, ReleasedThenDied).ShouldBeFalse("the receipt read after the death says the broker got past its verdict"); + } + finally { Directory.Delete(directory, recursive: true); } + } + + [Fact] + public async Task A_committed_receipt_left_by_another_broker_proves_nothing() + { + var directory = Directory.CreateTempSubdirectory("cs-broker-other-").FullName; + try + { + await PublishReceiptAsync(directory, "committed", DeadBroker with { StartKey = "another-broker" }); + + LocalProcessRunner.BrokerDiedBeforeItsVerdict(directory, DeadBroker).ShouldBeFalse("only the broker that was asked about can have left its own last word"); + } + finally { Directory.Delete(directory, recursive: true); } + } + + /// A broker identity no live process has — the shape a killed broker leaves in its receipt. + private static readonly NativeProcessIdentity DeadBroker = NativeProcess.Current with { StartKey = "a-start-no-process-has" }; + + /// Publish a receipt the way the bootstrap does — whole, by rename — so the poller can never read it half-written. + private static Task PublishReceiptAsync(string directory, string state, NativeProcessIdentity broker) + { + PublishReceipt(directory, state, broker); + return Task.CompletedTask; + } + + private static void PublishReceipt(string directory, string state, NativeProcessIdentity broker) + { + var staging = Path.Combine(directory, $"receipt-{Guid.NewGuid():N}.tmp"); + File.WriteAllText(staging, JsonSerializer.Serialize(new NativeLaunchReceipt { SpecHash = "h", Broker = broker, State = state }, NativeLaunchProtocol.Json)); + File.Move(staging, Path.Combine(directory, NativeLaunchProtocol.ReceiptFile), overwrite: true); + } + + [Fact] + public void A_broker_port_binds_the_launch_and_a_spec_without_one_serializes_as_before() + { + var spec = NativeLaunchProtocol.Freeze(new SandboxSpec { Command = "/bin/sh" }); + + NativeLaunchProtocol.SpecHash(spec with { ModelBrokerPort = 43121 }).ShouldNotBe(NativeLaunchProtocol.SpecHash(spec), "a launch sealed to a broker is a different execution from one severed from everything"); + JsonSerializer.Serialize(spec, NativeLaunchProtocol.Json).ShouldNotContain("modelBrokerPort", customMessage: "an unbrokered spec must serialize, and hash, exactly as it did before the field existed"); + } + [Fact] public void A_sandbox_stand_down_binds_the_launch_and_is_frozen_with_it() { diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/SandboxEgressPolicyTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/SandboxEgressPolicyTests.cs index 47d4d929f..1b1b8863e 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/SandboxEgressPolicyTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/SandboxEgressPolicyTests.cs @@ -18,6 +18,19 @@ public void No_network_is_None_regardless_of_an_allowlist() SandboxEgressPolicy.Derive(allowNetwork: false, allowlist: new[] { "api.anthropic.com" }, canEnforceAllowlist: true).Mode.ShouldBe(SandboxEgressMode.None); } + [Theory] + [InlineData(false, 43121, SandboxEgressMode.Sealed)] // network off + a broker it can be sealed to → sealed to that port + [InlineData(false, null, SandboxEgressMode.None)] // network off, nothing to seal to → severed, as always + [InlineData(true, 43121, SandboxEgressMode.Full)] // network on already reaches its broker — a port never narrows or widens it + public void A_network_off_run_is_sealed_only_to_a_broker_it_was_given(bool allowNetwork, int? sealablePort, SandboxEgressMode expected) + { + var policy = SandboxEgressPolicy.Derive(allowNetwork, allowlist: null, canEnforceAllowlist: true, sealablePort); + + policy.Mode.ShouldBe(expected); + policy.BrokerPort.ShouldBe(expected == SandboxEgressMode.Sealed ? sealablePort : null, "the sealed port is carried only by the sealed mode"); + policy.AllowedHosts.ShouldBeEmpty("a sealed run resolves no hosts — its one destination is an address, not a name"); + } + [Fact] public void Network_without_an_allowlist_is_Full_todays_behaviour() { diff --git a/frontend/src/api/sessions.ts b/frontend/src/api/sessions.ts index aac8b387f..e9a48f68f 100644 --- a/frontend/src/api/sessions.ts +++ b/frontend/src/api/sessions.ts @@ -341,7 +341,7 @@ export type RoomAgentLogStatus = "Verified" | "Captured" | "Finalizing" | "Incom /// What the sandbox actually did to one producer. `Unknown` is a real absence (nothing recorded it), never a /// confinement to render as safety — the renderer must say "posture unknown" rather than show a confined glyph. -export type RoomConfinementPosture = "Unknown" | "Unconfined" | "Confined" | "ConfinedNetworkSevered"; +export type RoomConfinementPosture = "Unknown" | "Unconfined" | "Confined" | "ConfinedNetworkSevered" | "ConfinedEgressSealedToBroker"; /// WHO produced one delivered artifact and under what conditions (P21-8b). Every field is recorded fact or an /// explicit absence: a null `costUsd` means UNPRICEABLE (never free), a null `logs` means no stream was declared diff --git a/frontend/src/components/sessions/SessionRoomView.artifact-producer.test.tsx b/frontend/src/components/sessions/SessionRoomView.artifact-producer.test.tsx index bb20aba55..b4b01290b 100644 --- a/frontend/src/components/sessions/SessionRoomView.artifact-producer.test.tsx +++ b/frontend/src/components/sessions/SessionRoomView.artifact-producer.test.tsx @@ -59,6 +59,7 @@ describe("per-artifact producer truth (P21-8b)", () => { ["Unconfined", "unconfined"], ["Confined", "confined"], ["ConfinedNetworkSevered", "confined · egress severed"], + ["ConfinedEgressSealedToBroker", "confined · egress sealed to its model broker"], ])("names the posture %s as its own word", (confinement, expected) => { render(); diff --git a/frontend/src/components/sessions/SessionRoomView.tsx b/frontend/src/components/sessions/SessionRoomView.tsx index 6b2e42ca7..e3a9fd380 100644 --- a/frontend/src/components/sessions/SessionRoomView.tsx +++ b/frontend/src/components/sessions/SessionRoomView.tsx @@ -1996,6 +1996,7 @@ const POSTURE_LABEL: Record = { Unconfined: "unconfined", Confined: "confined", ConfinedNetworkSevered: "confined \u00b7 egress severed", + ConfinedEgressSealedToBroker: "confined \u00b7 egress sealed to its model broker", }; /** A producer's own log health → the word the row shows. A producer that declared no stream renders none of this rather than a word that reads as settled. */ diff --git a/frontend/src/lib/networkPosture.fixture.json b/frontend/src/lib/networkPosture.fixture.json index a84ad9b55..6bc99ad2e 100644 --- a/frontend/src/lib/networkPosture.fixture.json +++ b/frontend/src/lib/networkPosture.fixture.json @@ -14,6 +14,7 @@ { "effective": "Standard", "ceiling": "Standard", "deployment": "Standard", "line": "Network: clamped off by deployment ceiling (Standard) — severed only where the sandbox confines" }, { "effective": "Trusted", "ceiling": "Trusted", "deployment": "Standard", "line": "Network: on (Trusted)" } ], + "_sealedDoc": "egressSealedToBroker marks a network-off run whose brokered model was reached through a namespace sealed to that broker (no route, no NAT, no DNS, one gateway port). It is severed from everything else, so networkSevered is true too, and the sentence names the one route it kept.", "_credentialDoc": "A confinement record also carries what the launch did about the MODEL CREDENTIAL (modelCredentialBrokered). Absent/null means the run injected none and the sentence says nothing; false means the tenant's own provider key went into the sandbox and the line discloses it (AgentAutonomyPolicy.DirectModelCredentialCaveat), on EVERY posture branch including 'on' — that is the run whose agent can spend the key. true says nothing, because for a brokered run the sentence would be false.", "confinementCases": [ { @@ -36,6 +37,11 @@ "confinement": { "outcome": "Confined", "networkSevered": true, "modelCredentialBrokered": true }, "line": "Network: off (Standard) — confined: egress severed" }, + { + "effective": "Standard", "ceiling": "Trusted", "deployment": "Unleashed", + "confinement": { "outcome": "Confined", "networkSevered": true, "egressSealedToBroker": true, "modelCredentialBrokered": true }, + "line": "Network: off (Standard) — confined: egress sealed to the run's model broker" + }, { "effective": "Trusted", "ceiling": "Trusted", "deployment": "Unleashed", "confinement": { "outcome": "Confined", "networkSevered": false, "modelCredentialBrokered": false },