From df90e31d6356ac16fd481fef512f7676cf73bcfb Mon Sep 17 00:00:00 2001 From: mintaka Date: Sat, 12 Sep 2026 00:07:22 -0400 Subject: [PATCH] feat(runtime): refuse an egress policy the host tier cannot enforce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A host child shares the host's network namespace, so the nftables arm the container tiers run has no boundary to attach to. The host backend now reports that through an egressUnenforcer marker, and provision refuses any configured policy instead of dropping it silently — a caller must never believe egress was constrained when nothing constrained it. The marker is deliberately separate from inGuestEgressArmer: that one means someone armed the firewall, this one that nobody did and nobody can. AgentRuntime.EgressPosture reports which, so a session surface can show an uncontained launch as uncontained. The refusal keys on the policy's presence, not a non-empty allowlist: an empty allowlist is pure default-deny, the strictest posture, so keying on length would reject a looser policy and silently discard the tightest one. --- go/internal/runtime/agent.go | 68 +++++++++++++++++++- go/internal/runtime/agent_test.go | 98 +++++++++++++++++++++++++++++ go/internal/runtime/host_backend.go | 9 +++ 3 files changed, 173 insertions(+), 2 deletions(-) diff --git a/go/internal/runtime/agent.go b/go/internal/runtime/agent.go index 02025682f..c9dc8867c 100644 --- a/go/internal/runtime/agent.go +++ b/go/internal/runtime/agent.go @@ -257,6 +257,16 @@ func (r *AgentRuntime) WriteAgentFile(ctx context.Context, id WorkloadID, uid ui return requireSuccess("write agent file", out) } +// EgressPosture reports how this runtime constrains agent egress, so a caller +// can surface it per session instead of inferring containment from a green +// launch. +func (r *AgentRuntime) EgressPosture() EgressPosture { + if r.egressUnenforced() { + return EgressUnenforcedPosture + } + return EgressArmed +} + // createAndStart creates then starts the container, cleaning up a created but // unstarted container so a retry with the same name starts clean. func (r *AgentRuntime) createAndStart(ctx context.Context, spec AgentSpec) (WorkloadID, error) { @@ -299,13 +309,57 @@ type inGuestEgressArmer interface { EgressArmedInGuest() bool } +// egressUnenforcer is a backend that cannot enforce an egress policy at all, +// because it has no isolation boundary to firewall — a host process shares the +// host's network namespace. It is deliberately distinct from +// inGuestEgressArmer: that marker means "someone armed it", this one means +// "nobody did and nobody can", and conflating them would report a contained +// posture for an uncontained launch. +type egressUnenforcer interface { + EgressUnenforced() bool +} + +// EgressPosture is how an agent's egress is constrained for the life of a +// workload: armed by a firewall, or structurally unenforceable on this tier. +type EgressPosture string + +const ( + // EgressArmed means a default-deny allowlist firewall is in force. + EgressArmed EgressPosture = "armed" + // EgressUnenforcedPosture means the tier cannot constrain egress; the agent + // reaches whatever the host reaches. + EgressUnenforcedPosture EgressPosture = "unenforced" +) + +// UnenforceableEgressPolicyError is returned when a launch carries an egress +// policy to a backend that cannot enforce one. Failing is deliberate: silently +// dropping the policy would leave the caller believing egress was constrained. +type UnenforceableEgressPolicyError struct { + Hosts []string +} + +func (e *UnenforceableEgressPolicyError) Error() string { + return fmt.Sprintf( + "host backend cannot enforce an egress policy (%d allowlisted host(s)): this tier shares the host network namespace, so egress is unenforced — drop the policy to launch here, or use a container tier to keep it", + len(e.Hosts), + ) +} + // provision runs the post-start steps, all inside the running container: // firewall (root), credentials (agent user), checkout dir (agent user). A // backend that self-arms egress in-guest (inGuestEgressArmer, the microVM // backend) has already armed by Start, so the host-side armEgress exec — which -// on that backend would run capability-less and fail — is skipped. +// on that backend would run capability-less and fail — is skipped. A backend +// that cannot enforce egress (egressUnenforcer) refuses any configured policy +// rather than dropping it. func (r *AgentRuntime) provision(ctx context.Context, id WorkloadID, spec AgentSpec) error { - if armer, ok := r.runtime.(inGuestEgressArmer); !ok || !armer.EgressArmedInGuest() { + switch { + case r.egressUnenforced(): + if spec.Egress.Configured() { + return &UnenforceableEgressPolicyError{Hosts: spec.Egress.Hosts()} + } + case r.selfArmsEgress(): + default: if err := r.armEgress(ctx, id, spec.Egress); err != nil { return err } @@ -316,6 +370,16 @@ func (r *AgentRuntime) provision(ctx context.Context, id WorkloadID, spec AgentS return r.ensureCheckoutDir(ctx, id, spec.Workspace) } +func (r *AgentRuntime) egressUnenforced() bool { + unenforcer, ok := r.runtime.(egressUnenforcer) + return ok && unenforcer.EgressUnenforced() +} + +func (r *AgentRuntime) selfArmsEgress() bool { + armer, ok := r.runtime.(inGuestEgressArmer) + return ok && armer.EgressArmedInGuest() +} + // armEgress arms the egress firewall as the image's default user (uid 1000) // with CAP_NET_ADMIN. After this, an agent exec — run as the agent uid with no // capabilities — cannot alter the ruleset. diff --git a/go/internal/runtime/agent_test.go b/go/internal/runtime/agent_test.go index d3f1b9289..8f6a1769d 100644 --- a/go/internal/runtime/agent_test.go +++ b/go/internal/runtime/agent_test.go @@ -337,6 +337,104 @@ func TestInGuestArmerSkipsHostArmEgress(t *testing.T) { } } +// unenforcedEgressFakeRuntime is a fakeRuntime that cannot enforce egress: it +// implements the egressUnenforcer marker, mirroring the host backend without +// spawning host children. Distinct from inGuestArmingFakeRuntime — that one +// claims egress WAS armed, this one that it cannot be. +type unenforcedEgressFakeRuntime struct { + *fakeRuntime +} + +func (f *unenforcedEgressFakeRuntime) EgressUnenforced() bool { return true } + +// TestUnenforcedEgressRefusesAConfiguredPolicy: a tier that cannot firewall +// must fail the launch rather than drop the policy, so a caller never believes +// egress was constrained when nothing constrained it. +func TestUnenforcedEgressRefusesAConfiguredPolicy(t *testing.T) { + fake := &unenforcedEgressFakeRuntime{fakeRuntime: newFakeRuntime(t)} + rt := NewAgentRuntime(fake) + + _, err := rt.Launch(t.Context(), specWithCreds(true)) + if err == nil { + t.Fatal("Launch with an egress policy on an unenforceable tier: err = nil, want refusal") + } + if _, ok := errors.AsType[*UnenforceableEgressPolicyError](err); !ok { + t.Fatalf("Launch error = %v, want UnenforceableEgressPolicyError", err) + } + if !strings.Contains(err.Error(), "cannot enforce an egress policy") { + t.Errorf("error %q does not contain \"cannot enforce an egress policy\"", err.Error()) + } + for _, e := range fake.execsSnapshot() { + if slices.ContainsFunc(e.Command, func(tok string) bool { return strings.Contains(tok, "compass_egress") }) { + t.Errorf("an unenforceable tier must not run the arm exec; command = %v", e.Command) + } + } +} + +// TestUnenforcedEgressLaunchesWithoutAPolicy: the zero-value policy is the +// supported host-tier path — no arm exec runs, and provision still completes. +// Paired with the refusal above, this is the presence-not-emptiness contract: +// an empty-but-configured allowlist is refused, an absent one launches. +func TestUnenforcedEgressLaunchesWithoutAPolicy(t *testing.T) { + fake := &unenforcedEgressFakeRuntime{fakeRuntime: newFakeRuntime(t)} + rt := NewAgentRuntime(fake) + spec := specWithCreds(true) + spec.Egress = EgressPolicy{} + + if _, err := rt.Launch(t.Context(), spec); err != nil { + t.Fatalf("Launch without an egress policy = %v, want success", err) + } + for _, e := range fake.execsSnapshot() { + if slices.ContainsFunc(e.Command, func(tok string) bool { return strings.Contains(tok, "compass_egress") }) { + t.Errorf("an unenforceable tier must not run the arm exec; command = %v", e.Command) + } + } + calls := fake.callsSnapshot() + if !slices.ContainsFunc(calls, func(c string) bool { return strings.Contains(c, "mkdir") }) { + t.Errorf("provision must still create the checkout dir; calls = %v", calls) + } +} + +// TestUnenforcedEgressRefusesAConfiguredEmptyAllowlist is the presence-vs-emptiness +// case: an empty allowlist is pure default-deny — the STRICTEST posture, not the +// absence of a policy. Keying the refusal on len(Hosts()) would reject a looser +// policy while silently discarding the tightest one. +func TestUnenforcedEgressRefusesAConfiguredEmptyAllowlist(t *testing.T) { + fake := &unenforcedEgressFakeRuntime{fakeRuntime: newFakeRuntime(t)} + rt := NewAgentRuntime(fake) + spec := specWithCreds(true) + spec.Egress = MustAllowEgress() + + _, err := rt.Launch(t.Context(), spec) + if err == nil { + t.Fatal("configured-but-empty allowlist on an unenforceable tier: err = nil, want refusal") + } + if _, ok := errors.AsType[*UnenforceableEgressPolicyError](err); !ok { + t.Fatalf("Launch error = %v, want UnenforceableEgressPolicyError", err) + } +} + +// TestEgressPostureReportsUnenforcedOnlyForUnenforceableTiers pins the posture +// the session surface renders: an unenforceable tier reads "unenforced", while +// an arming tier — including one that armed in-guest — reads "armed". A tier +// that self-armed must never be reported as unenforced. +func TestEgressPostureReportsUnenforcedOnlyForUnenforceableTiers(t *testing.T) { + unenforced := NewAgentRuntime(&unenforcedEgressFakeRuntime{fakeRuntime: newFakeRuntime(t)}) + if got := unenforced.EgressPosture(); got != EgressUnenforcedPosture { + t.Errorf("unenforceable tier: EgressPosture() = %q, want %q", got, EgressUnenforcedPosture) + } + + selfArming := NewAgentRuntime(&inGuestArmingFakeRuntime{fakeRuntime: newFakeRuntime(t)}) + if got := selfArming.EgressPosture(); got != EgressArmed { + t.Errorf("self-arming tier: EgressPosture() = %q, want %q", got, EgressArmed) + } + + hostArming := NewAgentRuntime(newFakeRuntime(t)) + if got := hostArming.EgressPosture(); got != EgressArmed { + t.Errorf("host-arming tier: EgressPosture() = %q, want %q", got, EgressArmed) + } +} + // TestCreateArgsIgnoresEgress pins the podman byte-identical constraint: setting // WorkloadSpec.Egress must not change the `podman create` argv at all. The // podman backend arms via AgentRuntime.armEgress, never from the spec field, so diff --git a/go/internal/runtime/host_backend.go b/go/internal/runtime/host_backend.go index e32073e26..cb711cdb6 100644 --- a/go/internal/runtime/host_backend.go +++ b/go/internal/runtime/host_backend.go @@ -151,6 +151,15 @@ func (h *HostRuntime) WorkspaceUID() (uint32, error) { return uint32(h.euid), nil //nolint:gosec // G115: euid is non-negative here (the < 0 case returned above), so the narrowing cannot wrap. } +// EgressUnenforced marks this tier as unable to constrain egress. A host child +// shares the host's network namespace, so there is no boundary to firewall — +// the nftables arm the container tiers run has nothing to attach to here. This +// reports the absence of enforcement, never that enforcement happened +// elsewhere. +func (h *HostRuntime) EgressUnenforced() bool { + return true +} + // WithTimeout overrides the per-command wall-clock cap Exec applies. func (h *HostRuntime) WithTimeout(timeout time.Duration) *HostRuntime { h.timeout = timeout