Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions go/cmd/compass-runner/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
100 changes: 100 additions & 0 deletions go/internal/runner/resolve_egress_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
36 changes: 36 additions & 0 deletions go/internal/runner/spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
21 changes: 13 additions & 8 deletions go/internal/runtime/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand All @@ -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
Expand Down
46 changes: 30 additions & 16 deletions go/internal/runtime/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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{}
Expand All @@ -393,14 +394,20 @@ 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
// 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)}
fake := &egressMarkerFakeRuntime{fakeRuntime: newFakeRuntime(t), unenforced: true}
rt := NewAgentRuntime(fake)
spec := specWithCreds(true)
spec.Egress = MustAllowEgress()
Expand All @@ -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)
}
}

Expand Down
Loading