From ab53b806d9f8764ae380c0d2be9c1506506b60d7 Mon Sep 17 00:00:00 2001 From: mintaka Date: Sat, 12 Sep 2026 00:03:07 -0400 Subject: [PATCH] feat(runtime): distinguish a configured egress policy from an unset one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An empty allowlist is the strictest posture — pure default-deny — while a zero-value policy means egress was never configured. The two were indistinguishable, so a tier that cannot enforce egress had no way to refuse a policy without also rejecting the tightest one. AllowEgress now marks the policy configured, and Configured() exposes it. Hosts() and NftScript() are untouched, so container arming is unchanged. --- go/internal/runtime/egress.go | 16 ++++++++++++++-- go/internal/runtime/egress_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/go/internal/runtime/egress.go b/go/internal/runtime/egress.go index d1bd782de..8ff4426b9 100644 --- a/go/internal/runtime/egress.go +++ b/go/internal/runtime/egress.go @@ -29,8 +29,13 @@ import ( // EgressPolicy is the set of destinations an agent container may reach. An empty // host set is pure default-deny (only loopback, established flows, and DNS to // the container's own resolver). +// +// The zero value means no policy was configured at all, which is distinct from +// a configured-but-empty allowlist: empty is the strictest posture, so a tier +// that cannot enforce egress has to tell the two apart to refuse the former. type EgressPolicy struct { - hosts []string + hosts []string + configured bool } // AllowEgress builds a policy allowing exactly hosts (deduplicated and @@ -50,7 +55,7 @@ func AllowEgress(hosts ...string) (EgressPolicy, error) { deduped = append(deduped, host) } sort.Strings(deduped) - return EgressPolicy{hosts: deduped}, nil + return EgressPolicy{hosts: deduped, configured: true}, nil } // MustAllowEgress is a convenience for known-good literals (tests, static config). @@ -68,6 +73,13 @@ func (e EgressPolicy) Hosts() []string { return e.hosts } +// Configured reports whether this policy came from a constructor rather than +// being a zero value. Callers that arm a firewall ignore this; it exists for +// tiers that must reject a policy they cannot enforce. +func (e EgressPolicy) Configured() bool { + return e.configured +} + // NftScript is the shell script an entrypoint runs (as root, with NET_ADMIN) to // arm the firewall: default-drop output with loopback / established carve-outs, // DNS restricted to the container's own resolver(s), then each allowlisted diff --git a/go/internal/runtime/egress_test.go b/go/internal/runtime/egress_test.go index 2b001af68..9e25e5d2b 100644 --- a/go/internal/runtime/egress_test.go +++ b/go/internal/runtime/egress_test.go @@ -73,6 +73,33 @@ func TestHostsAreDeduplicatedAndOrdered(t *testing.T) { } } +func TestConfiguredDistinguishesAnEmptyAllowlistFromNoPolicy(t *testing.T) { + // An empty allowlist is the strictest posture, not the absence of a policy; + // keying on len(Hosts()) would invert exactly the case this separates. + empty := MustAllowEgress() + if !empty.Configured() { + t.Error("MustAllowEgress() with no hosts: Configured() = false, want true (empty is default-deny, not unset)") + } + if got := empty.Hosts(); len(got) != 0 { + t.Errorf("MustAllowEgress().Hosts() = %q, want empty", got) + } + + var unset EgressPolicy + if unset.Configured() { + t.Error("zero-value EgressPolicy: Configured() = true, want false") + } +} + +func TestRejectedHostYieldsAnUnconfiguredPolicy(t *testing.T) { + policy, err := AllowEgress("github.com; rm -rf /") + if err == nil { + t.Fatal("AllowEgress with a shell-unsafe host: err = nil, want rejection") + } + if policy.Configured() { + t.Error("rejected AllowEgress: Configured() = true, want false (the returned policy must not read as enforceable)") + } +} + func TestEveryAllowlistedHostIsResolved(t *testing.T) { script := MustAllowEgress("github.com", "api.anthropic.com").NftScript()