From 6ab494dd4e973922ff3a35e5fc2fc36c82b0371f Mon Sep 17 00:00:00 2001 From: mintaka Date: Sat, 12 Sep 2026 09:24:00 -0400 Subject: [PATCH 1/3] feat(runner): carry no egress policy on a backend that cannot enforce one The operator's allowlist flag parses into a real policy even when empty, so a host-tier Runner would have handed provision a configured policy and failed every launch. Resolution now sits beside the uid probe, keyed off the same capability seam: an unenforceable backend carries the zero-value policy. An explicit allowlist is treated differently. It is operator intent to confine egress, and this tier cannot, so startup fails and names the remedy rather than discarding the request silently. --- go/cmd/compass-runner/main.go | 4 + go/internal/runner/resolve_egress_test.go | 100 ++++++++++++++++++++++ go/internal/runner/spec.go | 36 ++++++++ 3 files changed, 140 insertions(+) create mode 100644 go/internal/runner/resolve_egress_test.go 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) { From f5378dc0f5ce73f60c5ce2976dde48f0db6837c9 Mon Sep 17 00:00:00 2001 From: mintaka Date: Sat, 12 Sep 2026 09:46:38 -0400 Subject: [PATCH 2/3] fix(runtime): name the egress posture constants symmetrically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pair read EgressArmed and EgressUnenforcedPosture — one carrying the type suffix, the other not — in a value set the design expects to grow. The suffix was avoiding visual overlap with the EgressUnenforced marker method, which Go namespacing never required. Both are now prefixed; the rendered strings are unchanged. Also records why the unenforced case is tested first (a both-marker backend must refuse, not silently skip), marks the deliberately empty self-arming arm, warns that a decorator must re-expose the marker, and closes two test gaps: credentials on the unenforced path, and a marker answering false reading as armed. --- go/internal/runtime/agent.go | 20 ++++++++++------- go/internal/runtime/agent_test.go | 36 +++++++++++++++++++++---------- 2 files changed, 37 insertions(+), 19 deletions(-) diff --git a/go/internal/runtime/agent.go b/go/internal/runtime/agent.go index c9dc8867..ac0ba273 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,14 @@ 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 skipping the arm and reporting armed. 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..1203f6c4 100644 --- a/go/internal/runtime/agent_test.go +++ b/go/internal/runtime/agent_test.go @@ -343,15 +343,16 @@ func TestInGuestArmerSkipsHostArmEgress(t *testing.T) { // claims egress WAS armed, this one that it cannot be. type unenforcedEgressFakeRuntime struct { *fakeRuntime + unenforced bool } -func (f *unenforcedEgressFakeRuntime) EgressUnenforced() bool { return true } +func (f *unenforcedEgressFakeRuntime) 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 := &unenforcedEgressFakeRuntime{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 := &unenforcedEgressFakeRuntime{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 := &unenforcedEgressFakeRuntime{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(&unenforcedEgressFakeRuntime{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(&unenforcedEgressFakeRuntime{fakeRuntime: newFakeRuntime(t), unenforced: false}) + if got := answersFalse.EgressPosture(); got != EgressPostureArmed { + t.Errorf("a backend reporting EgressUnenforced()=false: EgressPosture() = %q, want %q", got, EgressPostureArmed) } } From 7caeb60780587d7e5d1db6731be673cfd4408918 Mon Sep 17 00:00:00 2001 From: mintaka Date: Sat, 12 Sep 2026 09:59:25 -0400 Subject: [PATCH 3/3] docs(runtime): correct the egress switch-order rationale The comment claimed the order prevents reporting armed, but EgressPosture asks the marker directly and is independent of it. What the order governs is refusing a policy instead of silently dropping it on the self-arm branch. The test fake is renamed to match what it became when its answer turned into a field: it parametrizes the marker rather than always being unenforced. --- go/internal/runtime/agent.go | 3 ++- go/internal/runtime/agent_test.go | 22 +++++++++++----------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/go/internal/runtime/agent.go b/go/internal/runtime/agent.go index ac0ba273..a6fdc155 100644 --- a/go/internal/runtime/agent.go +++ b/go/internal/runtime/agent.go @@ -356,7 +356,8 @@ func (e *UnenforceableEgressPolicyError) Error() string { // 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 skipping the arm and reporting armed. + // policy it cannot honour rather than taking the self-arm branch and + // silently dropping it. switch { case r.egressUnenforced(): if spec.Egress.Configured() { diff --git a/go/internal/runtime/agent_test.go b/go/internal/runtime/agent_test.go index 1203f6c4..7c17be8a 100644 --- a/go/internal/runtime/agent_test.go +++ b/go/internal/runtime/agent_test.go @@ -337,22 +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 f.unenforced } +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), unenforced: true} + fake := &egressMarkerFakeRuntime{fakeRuntime: newFakeRuntime(t), unenforced: true} rt := NewAgentRuntime(fake) _, err := rt.Launch(t.Context(), specWithCreds(true)) @@ -377,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), unenforced: true} + fake := &egressMarkerFakeRuntime{fakeRuntime: newFakeRuntime(t), unenforced: true} rt := NewAgentRuntime(fake) spec := specWithCreds(true) spec.Egress = EgressPolicy{} @@ -407,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), unenforced: true} + fake := &egressMarkerFakeRuntime{fakeRuntime: newFakeRuntime(t), unenforced: true} rt := NewAgentRuntime(fake) spec := specWithCreds(true) spec.Egress = MustAllowEgress() @@ -426,7 +426,7 @@ 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), unenforced: true}) + unenforced := NewAgentRuntime(&egressMarkerFakeRuntime{fakeRuntime: newFakeRuntime(t), unenforced: true}) if got := unenforced.EgressPosture(); got != EgressPostureUnenforced { t.Errorf("unenforceable tier: EgressPosture() = %q, want %q", got, EgressPostureUnenforced) } @@ -443,7 +443,7 @@ func TestEgressPostureReportsUnenforcedOnlyForUnenforceableTiers(t *testing.T) { // The marker is a question, not a type tag: implementing it while answering // false is an enforcing backend. - answersFalse := NewAgentRuntime(&unenforcedEgressFakeRuntime{fakeRuntime: newFakeRuntime(t), unenforced: false}) + 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) }