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
16 changes: 14 additions & 2 deletions go/internal/runtime/egress.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).
Expand All @@ -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
Expand Down
27 changes: 27 additions & 0 deletions go/internal/runtime/egress_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Loading