diff --git a/go/internal/runtime/agent.go b/go/internal/runtime/agent.go index 02025682..c9dc8867 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 d3f1b928..8f6a1769 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 e32073e2..cb711cdb 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