diff --git a/go/cmd/compass-runner/main.go b/go/cmd/compass-runner/main.go index 4b028ebc..db094ede 100644 --- a/go/cmd/compass-runner/main.go +++ b/go/cmd/compass-runner/main.go @@ -184,6 +184,10 @@ func newSpecBuilder(engine runtime.WorkloadRuntime, image string, egress runtime if err != nil { return nil, err } + egress, err = runner.ResolveEgress(engine, egress) + if err != nil { + return nil, err + } return runner.NewConfigSpecBuilder(runner.SpecDefaults{ Image: image, Egress: egress, diff --git a/go/internal/runner/resolve_egress_test.go b/go/internal/runner/resolve_egress_test.go new file mode 100644 index 00000000..3762d6f5 --- /dev/null +++ b/go/internal/runner/resolve_egress_test.go @@ -0,0 +1,100 @@ +//go:build unix + +package runner + +// ResolveEgress: the per-backend egress policy that lives beside the uid +// resolution. A backend that cannot enforce egress carries no policy at all, +// because provision refuses one that reaches an unenforceable tier — but an +// explicit allowlist is operator intent this tier cannot honor, so it fails +// startup rather than being discarded. + +import ( + "strings" + "testing" + + "github.com/RigelBuild/compass/go/internal/runtime" +) + +// unenforceableBackend is a WorkloadRuntime implementing the egressUnenforcer +// capability, standing in for the host backend without spawning host children. +type unenforceableBackend struct { + *pipeRuntime + unenforced bool +} + +func (b unenforceableBackend) EgressUnenforced() bool { return b.unenforced } + +// TestResolveEgressDropsTheDefaultPolicyOnAnUnenforceableBackend: the parsed +// default is a real configured policy even with an empty allowlist, and +// provision refuses any configured policy on this tier. So the resolver must +// hand back an unconfigured one, or every host launch would fail. +func TestResolveEgressDropsTheDefaultPolicyOnAnUnenforceableBackend(t *testing.T) { + parsed, err := runtime.AllowEgress() + if err != nil { + t.Fatalf("AllowEgress() error = %v", err) + } + if !parsed.Configured() { + t.Fatal("precondition: the parsed default must be a configured policy") + } + + resolved, err := ResolveEgress(unenforceableBackend{pipeRuntime: newPipeRuntime(), unenforced: true}, parsed) + if err != nil { + t.Fatalf("ResolveEgress with no allowlist = %v, want success", err) + } + if resolved.Configured() { + t.Error("resolved policy: Configured() = true, want false (an unenforceable tier must carry no policy)") + } +} + +// TestResolveEgressRefusesAnExplicitAllowlistOnAnUnenforceableBackend: a +// non-empty allowlist is explicit intent to confine egress. Zeroing it would +// deliver the opposite of what the operator asked for, silently, so startup +// fails and names both the count and the remedy. +func TestResolveEgressRefusesAnExplicitAllowlistOnAnUnenforceableBackend(t *testing.T) { + parsed := runtime.MustAllowEgress("github.com", "api.anthropic.com") + + _, err := ResolveEgress(unenforceableBackend{pipeRuntime: newPipeRuntime(), unenforced: true}, parsed) + if err == nil { + t.Fatal("ResolveEgress with an allowlist on an unenforceable backend: err = nil, want refusal") + } + if !strings.Contains(err.Error(), "cannot enforce an egress allowlist") { + t.Errorf("error %q does not contain \"cannot enforce an egress allowlist\"", err.Error()) + } + if !strings.Contains(err.Error(), "select a container backend") { + t.Errorf("error %q does not contain \"select a container backend\" — the remedy must be actionable", err.Error()) + } +} + +// TestResolveEgressLeavesAContainerBackendUntouched pins the byte-identical +// claim at this seam: a backend without the capability keeps the operator's +// policy exactly, allowlist and configured flag alike. +func TestResolveEgressLeavesAContainerBackendUntouched(t *testing.T) { + parsed := runtime.MustAllowEgress("github.com") + + resolved, err := ResolveEgress(newPipeRuntime(), parsed) + if err != nil { + t.Fatalf("ResolveEgress on a container backend = %v, want success", err) + } + if !resolved.Configured() { + t.Error("container backend: Configured() = false, want true") + } + if got := resolved.Hosts(); len(got) != 1 || got[0] != "github.com" { + t.Errorf("container backend: Hosts() = %q, want [github.com]", got) + } +} + +// TestResolveEgressHonorsAnUnenforcerReportingFalse: the capability is a +// question, not a type tag. A backend implementing it but answering false is an +// enforcing backend, so its policy must survive. +func TestResolveEgressHonorsAnUnenforcerReportingFalse(t *testing.T) { + parsed := runtime.MustAllowEgress("github.com") + + resolved, err := ResolveEgress(unenforceableBackend{pipeRuntime: newPipeRuntime(), unenforced: false}, parsed) + if err != nil { + t.Fatalf("ResolveEgress = %v, want success", err) + } + if !resolved.Configured() || len(resolved.Hosts()) != 1 { + t.Errorf("a backend reporting EgressUnenforced()=false must keep its policy; Configured()=%v Hosts()=%q", + resolved.Configured(), resolved.Hosts()) + } +} diff --git a/go/internal/runner/spec.go b/go/internal/runner/spec.go index eb814513..5cb3c503 100644 --- a/go/internal/runner/spec.go +++ b/go/internal/runner/spec.go @@ -106,6 +106,42 @@ func ResolveWorkspaceUID(engine runtime.WorkloadRuntime) (uint32, error) { return agentuid.AgentUID, nil } +// egressUnenforcer is the backend capability of declaring that it cannot +// constrain egress. The host backend implements it (a host child shares the +// host's network namespace, so there is no boundary to firewall); the container +// tiers do not, because each has a netns of its own to arm. +type egressUnenforcer interface { + EgressUnenforced() bool +} + +// Compile-time regression guard, mirroring workspaceUIDResolver above: a +// signature drift would otherwise silently restore the configured policy below +// and fail every host launch at first provision instead of at startup. +var _ egressUnenforcer = (*runtime.HostRuntime)(nil) + +// ResolveEgress decides the egress policy the Runner's specs carry. A backend +// that cannot enforce egress gets the zero-value policy — deliberately +// unconfigured, because AgentRuntime.provision refuses any policy that reaches +// an unenforceable tier, and the operator's parsed default is a real policy even +// when the allowlist is empty. +// +// A non-empty allowlist is different: it is explicit operator intent to confine +// egress, and this tier cannot. Zeroing it would silently deliver the opposite +// of what was asked, so startup fails instead. Every other backend keeps the +// parsed policy untouched. +func ResolveEgress(engine runtime.WorkloadRuntime, parsed runtime.EgressPolicy) (runtime.EgressPolicy, error) { + u, ok := engine.(egressUnenforcer) + if !ok || !u.EgressUnenforced() { + return parsed, nil + } + if hosts := parsed.Hosts(); len(hosts) > 0 { + return runtime.EgressPolicy{}, fmt.Errorf( + "this backend cannot enforce an egress allowlist, but %d host(s) were allowlisted: it runs agents as host processes sharing the host network namespace, so drop the allowlist to run here, or select a container backend to keep it", + len(hosts)) + } + return runtime.EgressPolicy{}, nil +} + // BuildSpec maps the request's agent account onto a full AgentSpec, filling // image/egress/workspace-layout from the defaults. func (b *configSpecBuilder) BuildSpec(req *compassv1.ProvisionAgentWorkspaceRequest) (runtime.AgentSpec, error) { diff --git a/go/internal/runtime/agent.go b/go/internal/runtime/agent.go index c9dc8867..a6fdc155 100644 --- a/go/internal/runtime/agent.go +++ b/go/internal/runtime/agent.go @@ -262,9 +262,9 @@ func (r *AgentRuntime) WriteAgentFile(ctx context.Context, id WorkloadID, uid ui // launch. func (r *AgentRuntime) EgressPosture() EgressPosture { if r.egressUnenforced() { - return EgressUnenforcedPosture + return EgressPostureUnenforced } - return EgressArmed + return EgressPostureArmed } // createAndStart creates then starts the container, cleaning up a created but @@ -314,7 +314,9 @@ type inGuestEgressArmer interface { // 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. +// posture for an uncontained launch. Like that marker, a WorkloadRuntime +// decorator must re-expose EgressUnenforced: swallowing it would report an +// uncontained launch as armed. type egressUnenforcer interface { EgressUnenforced() bool } @@ -324,11 +326,11 @@ type egressUnenforcer interface { 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 + // EgressPostureArmed means a default-deny allowlist firewall is in force. + EgressPostureArmed EgressPosture = "armed" + // EgressPostureUnenforced means the tier cannot constrain egress; the agent // reaches whatever the host reaches. - EgressUnenforcedPosture EgressPosture = "unenforced" + EgressPostureUnenforced EgressPosture = "unenforced" ) // UnenforceableEgressPolicyError is returned when a launch carries an egress @@ -353,12 +355,15 @@ func (e *UnenforceableEgressPolicyError) Error() string { // 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 { + // Unenforced is tested first so a backend claiming both markers refuses a + // policy it cannot honour rather than taking the self-arm branch and + // silently dropping it. switch { case r.egressUnenforced(): if spec.Egress.Configured() { return &UnenforceableEgressPolicyError{Hosts: spec.Egress.Hosts()} } - case r.selfArmsEgress(): + case r.selfArmsEgress(): // armed in-guest by Start; nothing host-side to do default: if err := r.armEgress(ctx, id, spec.Egress); err != nil { return err diff --git a/go/internal/runtime/agent_test.go b/go/internal/runtime/agent_test.go index 8f6a1769..7c17be8a 100644 --- a/go/internal/runtime/agent_test.go +++ b/go/internal/runtime/agent_test.go @@ -337,21 +337,22 @@ 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 { +// egressMarkerFakeRuntime is a fakeRuntime that answers the egressUnenforcer +// marker, mirroring the host backend without spawning host children. The answer +// is a field because the marker is a question: true models a tier that cannot +// enforce egress, false an enforcing one. +type egressMarkerFakeRuntime struct { *fakeRuntime + unenforced bool } -func (f *unenforcedEgressFakeRuntime) EgressUnenforced() bool { return true } +func (f *egressMarkerFakeRuntime) EgressUnenforced() bool { return f.unenforced } // 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)} + fake := &egressMarkerFakeRuntime{fakeRuntime: newFakeRuntime(t), unenforced: true} rt := NewAgentRuntime(fake) _, err := rt.Launch(t.Context(), specWithCreds(true)) @@ -376,7 +377,7 @@ func TestUnenforcedEgressRefusesAConfiguredPolicy(t *testing.T) { // 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)} + fake := &egressMarkerFakeRuntime{fakeRuntime: newFakeRuntime(t), unenforced: true} rt := NewAgentRuntime(fake) spec := specWithCreds(true) spec.Egress = EgressPolicy{} @@ -393,6 +394,12 @@ func TestUnenforcedEgressLaunchesWithoutAPolicy(t *testing.T) { 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) } + creds := slices.ContainsFunc(fake.execsSnapshot(), func(e ExecSpec) bool { + return e.Stdin != nil && strings.Contains(*e.Stdin, "git-credentials") + }) + if !creds { + t.Errorf("provision must still install credentials on the unenforced path; execs = %v", fake.execsSnapshot()) + } } // TestUnenforcedEgressRefusesAConfiguredEmptyAllowlist is the presence-vs-emptiness @@ -400,7 +407,7 @@ func TestUnenforcedEgressLaunchesWithoutAPolicy(t *testing.T) { // 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)} + fake := &egressMarkerFakeRuntime{fakeRuntime: newFakeRuntime(t), unenforced: true} rt := NewAgentRuntime(fake) spec := specWithCreds(true) spec.Egress = MustAllowEgress() @@ -419,19 +426,26 @@ func TestUnenforcedEgressRefusesAConfiguredEmptyAllowlist(t *testing.T) { // 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) + unenforced := NewAgentRuntime(&egressMarkerFakeRuntime{fakeRuntime: newFakeRuntime(t), unenforced: true}) + if got := unenforced.EgressPosture(); got != EgressPostureUnenforced { + t.Errorf("unenforceable tier: EgressPosture() = %q, want %q", got, EgressPostureUnenforced) } selfArming := NewAgentRuntime(&inGuestArmingFakeRuntime{fakeRuntime: newFakeRuntime(t)}) - if got := selfArming.EgressPosture(); got != EgressArmed { - t.Errorf("self-arming tier: EgressPosture() = %q, want %q", got, EgressArmed) + if got := selfArming.EgressPosture(); got != EgressPostureArmed { + t.Errorf("self-arming tier: EgressPosture() = %q, want %q", got, EgressPostureArmed) } hostArming := NewAgentRuntime(newFakeRuntime(t)) - if got := hostArming.EgressPosture(); got != EgressArmed { - t.Errorf("host-arming tier: EgressPosture() = %q, want %q", got, EgressArmed) + if got := hostArming.EgressPosture(); got != EgressPostureArmed { + t.Errorf("host-arming tier: EgressPosture() = %q, want %q", got, EgressPostureArmed) + } + + // The marker is a question, not a type tag: implementing it while answering + // false is an enforcing backend. + answersFalse := NewAgentRuntime(&egressMarkerFakeRuntime{fakeRuntime: newFakeRuntime(t), unenforced: false}) + if got := answersFalse.EgressPosture(); got != EgressPostureArmed { + t.Errorf("a backend reporting EgressUnenforced()=false: EgressPosture() = %q, want %q", got, EgressPostureArmed) } }