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
68 changes: 66 additions & 2 deletions go/internal/runtime/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
}
Expand All @@ -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.
Expand Down
98 changes: 98 additions & 0 deletions go/internal/runtime/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions go/internal/runtime/host_backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading