From f05932a4a18885d747c6210fea47764284e25614 Mon Sep 17 00:00:00 2001 From: HosniBelfeki Date: Thu, 10 Sep 2026 22:17:12 +0100 Subject: [PATCH 1/6] api: compile an egress label floor as a conjunction A caller's label requirement is a disjunction: LabelCheck emits `check if label(a) or label(b)`, because a caller naming several labels means any of them will do. An operator's egress floor cannot mean that. A peer attesting only the most permissive of several alternatives would satisfy the floor while sitting outside the boundary the operator drew. LabelFloorCheck compiles the same input as a single conjunctive body -- `check if label("jurisdiction","eu"), label("compliance","gdpr")` -- so every pair must hold. It takes a map, so a floor has no way to spell an alternative in the first place. Both are ordinary Biscuit checks, which is what lets a caller requirement and a floor be combined by adding each to the authorizer and letting it AND them; neither has to know the other exists. LabelsSatisfyFloor is the non-attested counterpart, for the one provider class with no Biscuit to check: a service local to the node. Egress.RequireLabels carries the floor in the node config schema. It is a block of its own rather than a field under attenuation, because the two answer opposite questions -- attenuation is what this node demands of peers that call it, egress is what it demands of peers it calls. TestLabelFloorCheckIsConjunctionUnlikeLabelCheck pins the difference on the compiled shape rather than the rendered string: a disjunction becomes one query body per alternative, a conjunction one body carrying every predicate. Making the floor a disjunction fails it. No enforcement yet; that follows. --- api/datalog.go | 69 ++++++++++++++++++++++++++++++++++ api/datalog_test.go | 90 +++++++++++++++++++++++++++++++++++++++++++++ api/policy.go | 34 +++++++++++++++-- 3 files changed, 189 insertions(+), 4 deletions(-) diff --git a/api/datalog.go b/api/datalog.go index 71ba6a48..b10e2ca6 100644 --- a/api/datalog.go +++ b/api/datalog.go @@ -575,6 +575,75 @@ func LabelCheck(required map[string]string) (biscuit.Check, error) { return parser.FromStringCheck("check if " + strings.Join(clauses, " or ")) } +// LabelFloorCheck compiles an operator's egress floor (see Egress.RequireLabels) +// into a single fail-closed check satisfied only when the token carries *every* +// pair: `check if label("jurisdiction", "eu"), label("compliance", "gdpr")`. +// +// The conjunction is the whole difference from LabelCheck, which is a +// disjunction because a caller naming several labels means "any of these will +// do". A floor cannot mean that: a peer attesting only the most permissive of +// several alternatives would satisfy the floor while sitting outside the +// boundary the operator drew. So a floor takes a map — one value per key, no +// way to spell an alternative — and requires all of it. +// +// Both are ordinary Biscuit checks, so a caller's requirement and a floor are +// combined by adding each to the authorizer and letting it AND them; neither +// needs to know about the other. +func LabelFloorCheck(required map[string]string) (biscuit.Check, error) { + if len(required) == 0 { + return biscuit.Check{}, fmt.Errorf("no required labels") + } + keys := make([]string, 0, len(required)) + for k := range required { + keys = append(keys, k) + } + sort.Strings(keys) + clauses := make([]string, 0, len(required)) + for _, k := range keys { + if err := ValidateLabelKey(k); err != nil { + return biscuit.Check{}, err + } + v := required[k] + if err := ValidateLabelValue(v); err != nil { + return biscuit.Check{}, err + } + clauses = append(clauses, fmt.Sprintf("%s(%q, %q)", FactLabel, k, v)) + } + return parser.FromStringCheck("check if " + strings.Join(clauses, ", ")) +} + +// LabelsSatisfyFloor reports whether claimed satisfies every pair of the floor. +// It is the non-attested counterpart of LabelFloorCheck, for the one provider +// class that has no Biscuit to check: a service local to this node, whose +// labels are its own configuration and so are complete. An empty floor is +// satisfied by anything, so callers may pass one unconditionally. +func LabelsSatisfyFloor(floor, claimed map[string]string) bool { + for k, v := range floor { + if claimed[k] != v { + return false + } + } + return true +} + +// LabelsContradictFloor reports whether claimed states a *different* value for +// some key the floor requires. +// +// Absence is not contradiction, which is the whole distinction from +// LabelsSatisfyFloor. Gossiped claims are a discovery hint and may carry only +// part of what a peer attests, so a peer silent on one pair of the floor may +// still satisfy all of it in its Biscuit. Only a conflicting value is grounds +// to skip such a peer before the gate has seen its attested facts; treating +// silence as failure would drop providers that are inside the boundary. +func LabelsContradictFloor(floor, claimed map[string]string) bool { + for k, v := range floor { + if got, stated := claimed[k]; stated && got != v { + return true + } + } + return false +} + // isExactService reports whether serviceStr resolves to a plain exact-match grant, as opposed to a // wildcard/prefix/suffix pattern which already collapses to a single, cheap fact via BuildServiceDatalogFact. // The classification is asked of BuildServiceDatalogFact rather than repeated here: a new wildcard shape diff --git a/api/datalog_test.go b/api/datalog_test.go index d9c45734..778984a2 100644 --- a/api/datalog_test.go +++ b/api/datalog_test.go @@ -618,3 +618,93 @@ func TestLabelFactsAndCheck(t *testing.T) { }) } } + +// A caller's requirement is a disjunction ("any of these will do") and an +// operator's egress floor is a conjunction ("all of these"). The difference is +// the reason both exist, so it is pinned on the compiled shape rather than on +// the rendered string: a disjunction becomes one query body per alternative, a +// conjunction one body carrying every predicate. +func TestLabelFloorCheckIsConjunctionUnlikeLabelCheck(t *testing.T) { + both := map[string]string{"jurisdiction": "eu", "compliance": "gdpr"} + + floor, err := LabelFloorCheck(both) + if err != nil { + t.Fatalf("LabelFloorCheck: %v", err) + } + if len(floor.Queries) != 1 { + t.Fatalf("a floor must compile to a single conjunctive body, got %d alternatives", len(floor.Queries)) + } + if got := len(floor.Queries[0].Body); got != len(both) { + t.Errorf("the floor's body carries %d predicates, want all %d", got, len(both)) + } + + caller, err := LabelCheck(both) + if err != nil { + t.Fatalf("LabelCheck: %v", err) + } + if len(caller.Queries) != len(both) { + t.Errorf("a caller requirement must compile to one body per alternative, got %d", len(caller.Queries)) + } +} + +func TestLabelFloorCheckRejectsBadInput(t *testing.T) { + if _, err := LabelFloorCheck(nil); err == nil { + t.Error("LabelFloorCheck(nil): expected error, got nil") + } + if _, err := LabelFloorCheck(map[string]string{"region": "bad,value"}); err == nil { + t.Error("LabelFloorCheck(invalid value): expected error, got nil") + } + if _, err := LabelFloorCheck(map[string]string{"bad key!": "v"}); err == nil { + t.Error("LabelFloorCheck(invalid key): expected error, got nil") + } +} + +func TestLabelsSatisfyFloor(t *testing.T) { + floor := map[string]string{"jurisdiction": "eu", "compliance": "gdpr"} + tests := []struct { + name string + claimed map[string]string + want bool + }{ + {"every pair present", map[string]string{"jurisdiction": "eu", "compliance": "gdpr", "region": "de"}, true}, + {"one pair missing", map[string]string{"jurisdiction": "eu"}, false}, + {"one pair wrong", map[string]string{"jurisdiction": "eu", "compliance": "hipaa"}, false}, + {"nothing claimed", nil, false}, + } + for _, tt := range tests { + if got := LabelsSatisfyFloor(floor, tt.claimed); got != tt.want { + t.Errorf("%s: LabelsSatisfyFloor = %v, want %v", tt.name, got, tt.want) + } + } + // An empty floor constrains nothing, so callers can pass it unconditionally. + if !LabelsSatisfyFloor(nil, nil) { + t.Error("an empty floor must be satisfied by anything") + } +} + +// The two floor predicates differ on one case, and it is the case that +// matters: a peer silent on a pair the floor requires. Gossip is partial, so +// silence must not read as failure before the gate has seen attested facts. +func TestLabelsContradictFloorTreatsSilenceAsUnknown(t *testing.T) { + floor := map[string]string{"jurisdiction": "eu", "compliance": "gdpr"} + + partial := map[string]string{"jurisdiction": "eu"} + if LabelsContradictFloor(floor, partial) { + t.Error("a claim silent on one pair does not contradict the floor") + } + if LabelsSatisfyFloor(floor, partial) { + t.Error("but it does not satisfy it either") + } + + conflicting := map[string]string{"jurisdiction": "us"} + if !LabelsContradictFloor(floor, conflicting) { + t.Error("a different value for a required key is a contradiction") + } + + if LabelsContradictFloor(floor, nil) { + t.Error("no claims at all cannot contradict anything") + } + if LabelsContradictFloor(floor, map[string]string{"jurisdiction": "eu", "compliance": "gdpr"}) { + t.Error("a fully satisfying claim must not read as a contradiction") + } +} diff --git a/api/policy.go b/api/policy.go index 235bbf17..57edc59f 100644 --- a/api/policy.go +++ b/api/policy.go @@ -41,10 +41,14 @@ type ServiceConfig struct { // NodeConfig defines the optional attenuation rules and static services for a specific SAM Node. type NodeConfig struct { - Version string `yaml:"version"` - Attenuation Attenuation `yaml:"attenuation"` - Services []ServiceConfig `yaml:"services"` - Labels map[string]string `yaml:"labels,omitempty"` + Version string `yaml:"version"` + Attenuation Attenuation `yaml:"attenuation"` + Services []ServiceConfig `yaml:"services"` + // Labels is what this node is, attested at enrollment; Egress is what it + // demands of the peers it talks to. Adjacent because they are read + // together and mean opposite directions. + Labels map[string]string `yaml:"labels,omitempty"` + Egress Egress `yaml:"egress"` } // NodeConfigVersionV1Alpha1 is the only node config schema this build understands. @@ -66,3 +70,25 @@ type Attenuation struct { Checks []string `yaml:"checks"` Rules []string `yaml:"rules"` } + +// Egress is the operator's outbound policy: what this node demands of the peers +// it talks to. Attenuation is the mirror of it — what this node demands of the +// peers that talk to *it* — and the two are deliberately separate blocks +// because they answer opposite questions. +type Egress struct { + // RequireLabels is a floor every remote provider must attest before this + // node will send it anything, whatever the caller asked for. Absent means + // no floor, which is the historical behaviour: the requirement is then + // whatever the caller supplied, and a caller that supplies nothing is + // unconstrained. + // + // Every pair must hold (AND), unlike a caller's requirement, where any one + // pair is enough (see LabelCheck vs LabelFloorCheck). A map gives one value + // per key, so a floor cannot express alternatives — that is the point: a + // floor with alternatives would let the weakest of them stand in for the + // rest. + // + // A floor naming a label no peer attests reaches nothing, which is a + // usable egress kill switch. + RequireLabels map[string]string `yaml:"require_labels,omitempty"` +} From f983da566560fb574532d5c6f20165b8e9249cb7 Mon Sep 17 00:00:00 2001 From: HosniBelfeki Date: Thu, 10 Sep 2026 22:17:39 +0100 Subject: [PATCH 2/6] node: enforce the operator's egress label floor Every egress label requirement came from the caller: the X-Sam-Required-Labels header on the inference and a2a surfaces, and the required_labels parameter on call_remote_tool. A caller that sent none was unconstrained, so the jurisdictional boundary was opt-in by the party it was meant to constrain. Local attenuation already gives the operator enforced control over who may call in; this is the same for who the node will call out to. egress.require_labels in sam-node.yaml is now a floor every provider must attest before the node sends it anything. Absent, nothing changes. Three parts, of which the first is the one that matters: - VerifyPeerLabels returned early when the caller required nothing. With a floor configured there is always something to check, so that short-circuit now accounts for it; leaving it would have meant a floor that any caller could waive by staying silent, which is the same fail-open shape as an empty requirement set. - checkPeerLabels adds the caller's check and the floor's check separately and lets the authorizer AND them. Merging them into one disjunction would let the caller's pairs stand in for the floor's, so a caller naming an unrelated label could widen exactly what the floor exists to bound. - rankProviders applies the floor too, because a local service has no biscuit and never reaches the gate; for a local, ranking is the only enforcement point there is. A remote is dropped here only when its gossiped claims already contradict the floor -- an unlabelled one goes on to the gate, which decides on attested facts. The gate's cache key now includes the floor. Keying on the caller's requirement alone would let a verdict reached under one floor be replayed under another after a config change. A malformed floor fails at load rather than at first use, so an operator who wrote one finds out at startup instead of on the request that needed it. The strict schema means a misspelled key is refused rather than silently leaving the node with no floor. Rejections are counted under their own reason, egress_floor_mismatch, so an operator can tell their floor apart from a caller's requirement when a request finds no provider. Tests cover the cases the design turns on: a caller requiring nothing is still gated; one pair short of the floor is not enough; a caller cannot widen the floor by naming another label; a local outside the floor is dropped; an unlabelled remote is left to the gate; and with no floor configured every path behaves as before. --- internal/node/config.go | 18 +++ internal/node/config_test.go | 61 ++++++++++ internal/node/labels_gate.go | 101 ++++++++++++---- internal/node/labels_gate_test.go | 176 ++++++++++++++++++++++++++++ internal/node/openai_facade.go | 6 + internal/node/openai_facade_test.go | 73 ++++++++++++ internal/node/openai_scorer.go | 51 +++++++- 7 files changed, 456 insertions(+), 30 deletions(-) diff --git a/internal/node/config.go b/internal/node/config.go index 8d34ce87..ee60b0e5 100644 --- a/internal/node/config.go +++ b/internal/node/config.go @@ -31,6 +31,11 @@ type NodeConfigComplete struct { Rules []biscuit.Rule Services []api.ServiceConfig Labels map[string]string + + // EgressRequireLabels is the operator's egress floor (api.Egress). Nil + // means no floor, so a caller's requirement — or the absence of one — + // stands on its own, as it always has. + EgressRequireLabels map[string]string } // LoadNodeConfig loads the node configuration from the specified path. @@ -75,6 +80,19 @@ func CompleteNodeConfig(config api.NodeConfig) (*NodeConfigComplete, error) { Labels: config.Labels, } + // Rejected at load, not at first use: a floor that cannot compile would + // otherwise fail open on the request that needed it, and an operator who + // wrote one is entitled to find out at startup instead. + if len(config.Egress.RequireLabels) > 0 { + if err := api.ValidateLabels(config.Egress.RequireLabels); err != nil { + return nil, fmt.Errorf("invalid egress.require_labels: %w", err) + } + if _, err := api.LabelFloorCheck(config.Egress.RequireLabels); err != nil { + return nil, fmt.Errorf("invalid egress.require_labels: %w", err) + } + complete.EgressRequireLabels = config.Egress.RequireLabels + } + for i, svc := range config.Services { if err := api.ValidateServiceFormat(svc.Type + "://" + svc.Name); err != nil { return nil, fmt.Errorf("invalid service config at index %d: %w", i, err) diff --git a/internal/node/config_test.go b/internal/node/config_test.go index a1cf687b..3b9190fb 100644 --- a/internal/node/config_test.go +++ b/internal/node/config_test.go @@ -336,3 +336,64 @@ func TestCompleteNodeConfig(t *testing.T) { t.Fatal("CompleteNodeConfig() with invalid Datalog: want error, got nil") } } + +func TestLoadNodeConfigEgressFloor(t *testing.T) { + write := func(t *testing.T, body string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "sam-node.yaml") + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return path + } + + t.Run("floor is loaded", func(t *testing.T) { + cfg, err := LoadNodeConfig(write(t, ` +version: v1alpha1 +egress: + require_labels: + jurisdiction: eu + compliance: gdpr +`)) + if err != nil { + t.Fatalf("LoadNodeConfig: %v", err) + } + if len(cfg.EgressRequireLabels) != 2 || + cfg.EgressRequireLabels["jurisdiction"] != "eu" || + cfg.EgressRequireLabels["compliance"] != "gdpr" { + t.Errorf("EgressRequireLabels = %v", cfg.EgressRequireLabels) + } + }) + + t.Run("absent block means no floor", func(t *testing.T) { + cfg, err := LoadNodeConfig(write(t, "version: v1alpha1\n")) + if err != nil { + t.Fatalf("LoadNodeConfig: %v", err) + } + if cfg.EgressRequireLabels != nil { + t.Errorf("no egress block must leave the floor nil, got %v", cfg.EgressRequireLabels) + } + }) + + // Rejected at load: a floor that cannot compile would otherwise fail open + // on the first request that needed it. + t.Run("a malformed floor fails startup", func(t *testing.T) { + for _, body := range []string{ + "version: v1alpha1\negress:\n require_labels:\n \"bad key!\": eu\n", + "version: v1alpha1\negress:\n require_labels:\n jurisdiction: \"has,comma\"\n", + "version: v1alpha1\negress:\n require_labels:\n jurisdiction: \"\"\n", + } { + if _, err := LoadNodeConfig(write(t, body)); err == nil { + t.Errorf("expected a load error for %q", body) + } + } + }) + + // The schema is strict, so a typo in the block name is refused rather than + // silently leaving the node with no floor. + t.Run("a misspelled key is refused, not ignored", func(t *testing.T) { + if _, err := LoadNodeConfig(write(t, "version: v1alpha1\negress:\n required_labels:\n jurisdiction: eu\n")); err == nil { + t.Error("require_labels misspelled as required_labels must fail the strict schema") + } + }) +} diff --git a/internal/node/labels_gate.go b/internal/node/labels_gate.go index 3c19efd0..f509aaea 100644 --- a/internal/node/labels_gate.go +++ b/internal/node/labels_gate.go @@ -89,29 +89,65 @@ const ( labelGateDialTimeout = 10 * time.Second ) -// labelGateKey builds a deterministic cache key from a required label set. -func labelGateKey(peerID peer.ID, required map[string]string) string { - keys := make([]string, 0, len(required)) - for k := range required { - keys = append(keys, k) - } - sort.Strings(keys) - key := peerID.String() - for _, k := range keys { - key += "|" + k + "=" + required[k] - } - return key +// labelGateKey builds a deterministic cache key from a required label set and +// the floor in force. The floor is part of the key because a verdict reached +// under one floor says nothing about another: keying on the caller's +// requirement alone would let a cached pass survive a configuration change. +// +// The encoding is unambiguous, and it is worth saying why, because the +// separators are ordinary printable characters that a label value is allowed +// to contain. What rules a collision out is that "=" is not: every entry is +// "|=", ValidateLabelKey admits only [a-zA-Z0-9_.-] so a key +// cannot hold a separator, and ValidateLabelValue rejects "=" so a value +// cannot forge the "|=" that starts an entry. A value may therefore +// contain "|" or "#floor" freely without shifting where any boundary falls, +// and no pair of distinct (required, floor) inputs can render the same string. +func labelGateKey(peerID peer.ID, required, floor map[string]string) string { + render := func(sb *strings.Builder, m map[string]string) { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + sb.WriteByte('|') + sb.WriteString(k) + sb.WriteByte('=') + sb.WriteString(m[k]) + } + } + var sb strings.Builder + sb.WriteString(peerID.String()) + render(&sb, required) + sb.WriteString("#floor") + render(&sb, floor) + return sb.String() +} + +// egressFloor returns the operator's egress floor, or nil when none is +// configured (api.Egress.RequireLabels). +func (n *SamNode) egressFloor() map[string]string { + // Nil-safe for the same reason labels() is: NewSamNode always leaves + // nodeConfig set, but tests build SamNode directly. + if n.nodeConfig == nil { + return nil + } + return n.nodeConfig.EgressRequireLabels } // VerifyPeerLabels ensures the peer holds control-plane-attested labels -// satisfying any of the required key=value pairs (canonical, pre-validated). -// A nil/empty requirement passes without network traffic. Positive verdicts -// are cached. +// satisfying the caller's requirement (any one pair) and the operator's egress +// floor (every pair). Positive verdicts are cached. +// +// The gate runs whenever either exists. A caller that requires nothing is +// still held to the floor — the whole point of a floor is that the party being +// constrained does not get to opt out of it by saying nothing. func (n *SamNode) VerifyPeerLabels(ctx context.Context, peerID peer.ID, required map[string]string) error { - if len(required) == 0 { + floor := n.egressFloor() + if len(required) == 0 && len(floor) == 0 { return nil } - key := labelGateKey(peerID, required) + key := labelGateKey(peerID, required, floor) if until, ok := n.peerLabelGate.Get(key); ok && time.Now().Before(until) { return nil } @@ -129,11 +165,12 @@ func (n *SamNode) VerifyPeerLabels(ctx context.Context, peerID peer.ID, required } // checkPeerLabels verifies the provider's biscuit (control-plane signature, -// expiry, binding to peerID) and evaluates the compiled label requirement -// against its attested facts. +// expiry, binding to peerID) and evaluates the caller's requirement and the +// operator's egress floor against its attested facts. func (n *SamNode) checkPeerLabels(providerBiscuit []byte, peerID peer.ID, required map[string]string) error { + floor := n.egressFloor() if len(providerBiscuit) == 0 { - return fmt.Errorf("provider %s returned no identity biscuit; cannot attest required labels %v", peerID, required) + return fmt.Errorf("provider %s returned no identity biscuit; cannot attest required labels %v (egress floor %v)", peerID, required, floor) } n.keysMu.RLock() @@ -155,13 +192,29 @@ func (n *SamNode) checkPeerLabels(providerBiscuit []byte, peerID peer.ID, requir if err != nil { return fmt.Errorf("provider %s authorizer instantiation failed: %w", peerID, err) } - check, err := api.LabelCheck(required) - if err != nil { - return err + // Two checks, not one merged set. The authorizer ANDs them, so the peer + // has to satisfy both independently; merging them into a single + // disjunction would let the caller's pairs stand in for the floor's and + // widen exactly what the floor exists to bound. + if len(required) > 0 { + check, err := api.LabelCheck(required) + if err != nil { + return err + } + authorizer.AddCheck(check) + } + if len(floor) > 0 { + check, err := api.LabelFloorCheck(floor) + if err != nil { + return err + } + authorizer.AddCheck(check) } - authorizer.AddCheck(check) authorizer.AddPolicy(api.AllowIfTruePolicy) if err := authorizer.Authorize(); err != nil { + if len(floor) > 0 { + return fmt.Errorf("provider %s does not attest the caller requirement %v and the egress floor %v: %w", peerID, required, floor, err) + } return fmt.Errorf("provider %s has no attested label matching %v: %w", peerID, required, err) } return nil diff --git a/internal/node/labels_gate_test.go b/internal/node/labels_gate_test.go index 972283e6..31c0c5f5 100644 --- a/internal/node/labels_gate_test.go +++ b/internal/node/labels_gate_test.go @@ -15,12 +15,14 @@ package node import ( + "context" "crypto/ed25519" "testing" "time" "github.com/google/sam/api" "github.com/google/sam/internal/identity" + lru "github.com/hashicorp/golang-lru/v2" "github.com/libp2p/go-libp2p/core/peer" ) @@ -86,3 +88,177 @@ func TestCheckPeerLabels(t *testing.T) { } }) } + +// The egress floor is the operator's, and a caller cannot waive it by asking +// for nothing or by asking for something else. It is ANDed with the caller's +// requirement, so both hold independently: the caller may narrow the choice of +// provider, never widen it past the floor. +func TestCheckPeerLabelsEnforcesEgressFloor(t *testing.T) { + cpPub, cpPriv, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + providerPeer := peer.ID("provider-peer-id") + expiry := time.Now().Add(time.Hour) + + mint := func(labels map[string]string) []byte { + t.Helper() + b, err := identity.MintBootstrapBiscuitToken(cpPriv, providerPeer, api.RoleNode, expiry, nil, labels) + if err != nil { + t.Fatalf("mint: %v", err) + } + return b + } + + nodeWithFloor := func(floor map[string]string) *SamNode { + return &SamNode{ + trustedKeys: []TrustedKey{{Key: cpPub, ReceivedAt: time.Now()}}, + BiscuitTimeout: 500 * time.Millisecond, + nodeConfig: &NodeConfigComplete{EgressRequireLabels: floor}, + } + } + + euGdpr := map[string]string{"jurisdiction": "eu", "compliance": "gdpr"} + + tests := []struct { + name string + floor map[string]string + required map[string]string + attested map[string]string + expectErr bool + }{{ + name: "caller requires nothing but the floor still applies", + floor: map[string]string{"jurisdiction": "eu"}, + required: nil, + attested: map[string]string{"jurisdiction": "eu"}, + }, { + name: "caller requires nothing and the provider is outside the floor", + floor: map[string]string{"jurisdiction": "eu"}, + required: nil, + attested: map[string]string{"jurisdiction": "us"}, + expectErr: true, + }, { + name: "the floor is a conjunction: one pair short is not enough", + floor: euGdpr, + required: nil, + attested: map[string]string{"jurisdiction": "eu"}, + expectErr: true, + }, { + name: "the floor is satisfied when every pair is attested", + floor: euGdpr, + required: nil, + attested: map[string]string{"jurisdiction": "eu", "compliance": "gdpr", "region": "de"}, + }, { + // The whole reason the two are separate checks. Merged into one + // disjunction, region=us-east-1 alone would satisfy it. + name: "a caller cannot widen the floor by naming another label", + floor: map[string]string{"jurisdiction": "eu"}, + required: map[string]string{"region": "us-east-1"}, + attested: map[string]string{"jurisdiction": "us", "region": "us-east-1"}, + expectErr: true, + }, { + name: "caller and floor both satisfied", + floor: map[string]string{"jurisdiction": "eu"}, + required: map[string]string{"region": "de-txl"}, + attested: map[string]string{"jurisdiction": "eu", "region": "de-txl"}, + }, { + name: "caller narrows within the floor and the provider misses the narrowing", + floor: map[string]string{"jurisdiction": "eu"}, + required: map[string]string{"region": "de-txl"}, + attested: map[string]string{"jurisdiction": "eu", "region": "fr-par"}, + expectErr: true, + }, { + name: "no floor configured leaves the caller's requirement alone", + floor: nil, + required: map[string]string{"region": "eu"}, + attested: map[string]string{"region": "eu"}, + expectErr: false, + }} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := nodeWithFloor(tt.floor).checkPeerLabels(mint(tt.attested), providerPeer, tt.required) + if tt.expectErr && err == nil { + t.Errorf("expected rejection, got nil") + } + if !tt.expectErr && err != nil { + t.Errorf("expected acceptance, got %v", err) + } + }) + } +} + +// VerifyPeerLabels short-circuits when there is nothing to check. With a floor +// configured there always is, so the short-circuit must not swallow it: the +// gate has to run even for a caller that asked for nothing, which is the +// difference between a floor and a suggestion. +func TestVerifyPeerLabelsDoesNotShortCircuitPastTheFloor(t *testing.T) { + node := &SamNode{ + BiscuitTimeout: 500 * time.Millisecond, + nodeConfig: &NodeConfigComplete{EgressRequireLabels: map[string]string{"jurisdiction": "eu"}}, + } + cache, err := lru.New[string, time.Time](8) + if err != nil { + t.Fatal(err) + } + node.peerLabelGate = cache + + // No identity, so the biscuit fetch cannot succeed. Reaching that failure + // is the proof the gate ran at all; returning nil would mean it skipped. + err = node.VerifyPeerLabels(context.Background(), peer.ID("some-peer"), nil) + if err == nil { + t.Fatal("a caller requiring nothing must still be gated when a floor is configured") + } + + // Without a floor, the same call is the documented no-op. + node.nodeConfig = &NodeConfigComplete{} + if err := node.VerifyPeerLabels(context.Background(), peer.ID("some-peer"), nil); err != nil { + t.Errorf("with no floor and no requirement the gate must not run: %v", err) + } +} + +// The cache key has to separate the caller's requirement from the floor, or a +// pass recorded under one floor could be replayed under another. +func TestLabelGateKeySeparatesFloorFromRequirement(t *testing.T) { + p := peer.ID("peer") + a := labelGateKey(p, map[string]string{"jurisdiction": "eu"}, nil) + b := labelGateKey(p, nil, map[string]string{"jurisdiction": "eu"}) + if a == b { + t.Errorf("a requirement and a floor with the same pair must not share a cache key (%q)", a) + } +} + +// The key's separators are printable characters a label value is allowed to +// contain, so the encoding's unambiguity rests on ValidateLabelValue rejecting +// "=": a value can hold "|" or "#floor" but cannot forge the "|=" that +// begins an entry. These are the shapes that would collide if it could. +func TestLabelGateKeyIsUnambiguousWithSeparatorsInValues(t *testing.T) { + p := peer.ID("peer") + // Every value here is valid input: the validator rejects only a comma, an + // equals sign, and the three whitespace control characters. + for _, v := range []string{"eu|x", "eu#floor", "#floor|b", "a|b#floor|c"} { + if err := api.ValidateLabelValue(v); err != nil { + t.Fatalf("test premise wrong, %q is not a valid label value: %v", v, err) + } + } + + seen := map[string][]string{} + add := func(desc string, required, floor map[string]string) { + k := labelGateKey(p, required, floor) + seen[k] = append(seen[k], desc) + } + add("value carrying the entry separator", map[string]string{"a": "eu|x"}, nil) + add("two entries", map[string]string{"a": "eu", "x": "1"}, nil) + add("value carrying the floor marker", map[string]string{"a": "eu#floor"}, nil) + add("floor holding the same pair", nil, map[string]string{"a": "eu"}) + add("requirement holding the same pair", map[string]string{"a": "eu"}, nil) + add("value that looks like a floor section", map[string]string{"a": "#floor|b"}, nil) + add("split across both sets", map[string]string{"a": "eu"}, map[string]string{"b": "1"}) + add("both sets, values with separators", map[string]string{"a": "a|b#floor|c"}, map[string]string{"b": "1"}) + + for key, descs := range seen { + if len(descs) > 1 { + t.Errorf("these distinct inputs share cache key %q: %v", key, descs) + } + } +} diff --git a/internal/node/openai_facade.go b/internal/node/openai_facade.go index 481533ad..a00eb112 100644 --- a/internal/node/openai_facade.go +++ b/internal/node/openai_facade.go @@ -81,6 +81,11 @@ type openAIFacade struct { // peerLabels resolves a peer's gossip-observed labels for providers // discovered via the registry probe, which carries no labels. peerLabels func(peerID string) map[string]string + // egressFloor (may be nil) is the operator's egress floor. Remotes are + // held to it by the label gate, on attested facts; a local service has no + // biscuit and no gate, so ranking is the only place its floor can be + // applied at all. + egressFloor func() map[string]string // Label gate seam (may be nil = enforcement unavailable, fail closed // when a requirement exists): verifies the provider's // control-plane-attested labels before any request data is sent @@ -141,6 +146,7 @@ func newOpenAIFacade(node *SamNode, egress http.Handler) *openAIFacade { return node.revokedPeers != nil && node.revokedPeers.Contains(peerID) }, localLabels: node.labels, + egressFloor: node.egressFloor, peerLabels: func(peerID string) map[string]string { if node.Discovery == nil { return nil diff --git a/internal/node/openai_facade_test.go b/internal/node/openai_facade_test.go index 748b2047..956c4caf 100644 --- a/internal/node/openai_facade_test.go +++ b/internal/node/openai_facade_test.go @@ -823,3 +823,76 @@ func TestAttemptWriter(t *testing.T) { } }) } + +// A local service has no biscuit, so the label gate can never speak for it and +// ranking is the only place an egress floor can apply. A remote is filtered +// here only on claims that already contradict the floor; an unlabelled one is +// left for the gate, which decides on attested facts. +func TestRankProvidersEnforcesEgressFloor(t *testing.T) { + floor := map[string]string{"jurisdiction": "eu", "compliance": "gdpr"} + + newFacadeWithFloor := func(local map[string]string, peer map[string]string) *openAIFacade { + f := newTestFacade() + f.egressFloor = func() map[string]string { return floor } + f.localLabels = func() map[string]string { return local } + f.peerLabels = func(string) map[string]string { return peer } + return f + } + + t.Run("a local outside the floor is dropped", func(t *testing.T) { + f := newFacadeWithFloor(map[string]string{"jurisdiction": "us"}, nil) + got := f.rankProviders([]modelProvider{{service: "local"}}, nil) + if len(got) != 0 { + t.Errorf("a local that does not satisfy the floor must not be used: got %+v", got) + } + }) + + t.Run("a local one pair short is dropped", func(t *testing.T) { + f := newFacadeWithFloor(map[string]string{"jurisdiction": "eu"}, nil) + if got := f.rankProviders([]modelProvider{{service: "local"}}, nil); len(got) != 0 { + t.Errorf("the floor is a conjunction: got %+v", got) + } + }) + + t.Run("a local satisfying every pair survives", func(t *testing.T) { + f := newFacadeWithFloor(map[string]string{"jurisdiction": "eu", "compliance": "gdpr"}, nil) + if got := f.rankProviders([]modelProvider{{service: "local"}}, nil); len(got) != 1 { + t.Errorf("a local inside the floor must be usable: got %+v", got) + } + }) + + t.Run("a remote whose claims contradict the floor is dropped early", func(t *testing.T) { + f := newFacadeWithFloor(nil, nil) + p := modelProvider{peerID: "peerUS", service: "srv", labels: map[string]string{"jurisdiction": "us"}} + if got := f.rankProviders([]modelProvider{p}, nil); len(got) != 0 { + t.Errorf("a remote claiming outside the floor need not be dialled: got %+v", got) + } + }) + + // Gossip carries only part of what a peer attests, so a remote silent on + // one pair of the floor may still satisfy all of it in its Biscuit. + // Dropping it here would exclude a provider that is inside the boundary. + t.Run("a remote gossiping only part of the floor is left to the gate", func(t *testing.T) { + f := newFacadeWithFloor(nil, nil) + p := modelProvider{peerID: "peerEU", service: "srv", labels: map[string]string{"jurisdiction": "eu"}} + if got := f.rankProviders([]modelProvider{p}, nil); len(got) != 1 { + t.Errorf("silence on a pair is not a contradiction; the gate decides: got %+v", got) + } + }) + + t.Run("an unlabelled remote is left to the gate", func(t *testing.T) { + f := newFacadeWithFloor(nil, nil) + p := modelProvider{peerID: "peerUnknown", service: "srv"} + if got := f.rankProviders([]modelProvider{p}, nil); len(got) != 1 { + t.Errorf("ranking must not reject on absent claims; the gate decides: got %+v", got) + } + }) + + t.Run("no floor configured leaves ranking unchanged", func(t *testing.T) { + f := newTestFacade() + f.localLabels = func() map[string]string { return map[string]string{"jurisdiction": "us"} } + if got := f.rankProviders([]modelProvider{{service: "local"}}, nil); len(got) != 1 { + t.Errorf("without a floor a local is unconstrained: got %+v", got) + } + }) +} diff --git a/internal/node/openai_scorer.go b/internal/node/openai_scorer.go index b7a34561..055cee7d 100644 --- a/internal/node/openai_scorer.go +++ b/internal/node/openai_scorer.go @@ -18,6 +18,8 @@ import ( "net/http" "sort" "time" + + "github.com/google/sam/api" ) // providerBackoff is how long a provider is skipped after a retryable failure. @@ -25,14 +27,27 @@ const providerBackoff = 15 * time.Second // Rejection reasons for facade provider filtering (metric label values). const ( - reasonPeerRevoked = "peer_revoked" - reasonProviderBackoff = "provider_backoff" - reasonLabelMismatch = "label_mismatch" - reasonLabelUnattested = "label_unattested" - reasonNoEligible = "no_eligible_provider" - reasonAttemptsExceeded = "attempts_exceeded" + reasonPeerRevoked = "peer_revoked" + reasonProviderBackoff = "provider_backoff" + reasonLabelMismatch = "label_mismatch" + // reasonEgressFloorMismatch is kept distinct from reasonLabelMismatch so + // an operator can tell their own floor apart from a caller's requirement + // when a request finds no provider. + reasonEgressFloorMismatch = "egress_floor_mismatch" + reasonLabelUnattested = "label_unattested" + reasonNoEligible = "no_eligible_provider" + reasonAttemptsExceeded = "attempts_exceeded" ) +// floor returns the operator's egress floor, or nil when the seam is unset +// (tests) or no floor is configured. +func (f *openAIFacade) floor() map[string]string { + if f.egressFloor == nil { + return nil + } + return f.egressFloor() +} + // labelsAllowed reports whether a provider's claimed labels satisfy any // required key=value pair (exact match). func labelsAllowed(required, claimed map[string]string) bool { @@ -71,6 +86,30 @@ func (f *openAIFacade) rankProviders(providers []modelProvider, requiredLabels m continue } } + // The operator's egress floor, which the caller cannot waive. Every + // pair must hold, so this is not labelsAllowed. A remote is dropped + // here only on a claim that already contradicts the floor; the gate + // still decides on attested facts. A local is decided here for good, + // because it has no biscuit to attest anything. + if floor := f.floor(); len(floor) > 0 { + // A local is settled here: its labels are its own configuration, + // it has no Biscuit, and it never reaches the gate, so the floor + // must hold in full. + // + // A remote is only skipped on a claim that conflicts. Gossip may + // carry part of what a peer attests, so silence on a pair of the + // floor is not failure — the gate resolves it on attested facts. + var outside bool + if p.peerID == "" { + outside = !api.LabelsSatisfyFloor(floor, labels) + } else { + outside = api.LabelsContradictFloor(floor, labels) + } + if outside { + recordFacadeRejection(reasonEgressFloorMismatch) + continue + } + } if p.peerID == "" { locals = append(locals, p) continue From fe7b1e450df4c437dcc86168b996fe4c416cc72d Mon Sep 17 00:00:00 2001 From: HosniBelfeki Date: Thu, 10 Sep 2026 22:18:01 +0100 Subject: [PATCH 3/6] docs: document the egress floor, and stop promising it without one The sovereignty checklist told operators to "Direct agents to specify X-Sam-Required-Labels: jurisdiction=eu on all inference and MCP requests to guarantee zero data leakage beyond authorized perimeters". Directing an agent is not a guarantee: the field's own contract says absent means any provider policy allows, so an agent that omitted the header left the perimeter open. That mattered in a document that cites GDPR Chapter V and the EU Cloud Sovereignty Framework, where a reader may be deciding whether the control satisfies an obligation. It now points at egress.require_labels, which is enforced, and says what the header can still do: narrow further, never widen or waive. node-configuration gains the block alongside the header it complements, with the asymmetry in meaning stated plainly, since the same YAML shape means different things on the two sides: X-Sam-Required-Labels (caller) -> any pair is enough egress.require_labels (operator) -> every pair must hold That follows from what each is for -- a caller is choosing among acceptable providers, an operator is drawing a boundary -- but it is not guessable from the syntax, so it is written down. --- site/content/docs/sovereignty.md | 2 +- site/content/docs/user/node-configuration.md | 22 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/site/content/docs/sovereignty.md b/site/content/docs/sovereignty.md index 91160e21..2b9cd1c6 100644 --- a/site/content/docs/sovereignty.md +++ b/site/content/docs/sovereignty.md @@ -135,5 +135,5 @@ When deploying SAM for mission-critical, sovereign agent operations: 2. **Maintain Root Cryptographic Key Custody:** Generate, manage, and hold your own Ed25519 root signing keys (via KMS/Cloud EKM or HSMs). 3. **Use Your Own OIDC Identity Provider:** Point `--issuer` to your internal Keycloak, Dex, or corporate IdP. 4. **Declare & Attest Sovereignty Labels:** Declare `labels: {jurisdiction: eu, region: }` in each node's `sam-node.yaml` and configure control plane roles with `allowed_labels`. -5. **Enforce Jurisdictional Egress:** Direct agents to specify `X-Sam-Required-Labels: jurisdiction=eu` on all inference and MCP requests to guarantee zero data leakage beyond authorized perimeters. +5. **Enforce Jurisdictional Egress:** Set `egress.require_labels` in `sam-node.yaml` (e.g. `jurisdiction: eu`) so every provider must attest the boundary before the node sends it anything. Agents may narrow further per request with `X-Sam-Required-Labels`, but cannot widen past the floor or waive it by omitting the header — the two are checked independently and every pair of the floor must hold. 6. **Set Local Attenuation Vetoes:** Configure local node `attenuation.policies` to retain final destination-side access control. diff --git a/site/content/docs/user/node-configuration.md b/site/content/docs/user/node-configuration.md index 05e8da68..07af2184 100644 --- a/site/content/docs/user/node-configuration.md +++ b/site/content/docs/user/node-configuration.md @@ -141,6 +141,28 @@ The `call_remote_tool` MCP tool accepts the same requirement via its `required_l Enforcement is fail-closed and cryptographic: gossiped labels only rank candidate providers, and before any request data leaves your node the sidecar verifies the provider's control-plane-signed Biscuit and checks its attested `label()` facts. Providers that return no identity or lack a matching fact are rejected. +### Requiring labels of every provider (operator floor) + +The header above is the caller's requirement, and a caller that sends no header is unconstrained. To hold a boundary the caller cannot waive, set a floor in the node config: + +```yaml +egress: + require_labels: # every pair must hold, and callers cannot widen it + jurisdiction: eu + compliance: gdpr +``` + +Every remote provider must then attest all of these before the node sends it anything, whether or not the caller asked for labels. A caller may still narrow the choice further with `X-Sam-Required-Labels`; the two are checked independently, so a caller naming an unrelated label cannot stand in for the floor. + +Note the difference in meaning between the two, which follows from what each is for: + +| | Semantics | +|---|---| +| `X-Sam-Required-Labels` (caller) | **any** pair is enough — the caller is choosing among acceptable providers | +| `egress.require_labels` (operator) | **every** pair must hold — the operator is drawing a boundary | + +A floor naming a label no provider attests reaches nothing, which makes it a usable egress kill switch. Omit the block entirely to keep the previous behaviour, where the requirement is whatever the caller supplied. + ### Restricting callers by label (provider) Because every enrolled node's token carries its attested label facts, a provider can require callers to hold a label with a single local check in its `attenuation` block: From 881915bb13d9f921f5d28c929a03314dc91f3a60 Mon Sep 17 00:00:00 2001 From: Antonio Ojea Date: Tue, 15 Sep 2026 20:25:39 +0000 Subject: [PATCH 4/6] node: hold the egress floor when the caller requires nothing The floor was enforced inside VerifyPeerLabels and checkPeerLabels, but every egress surface gated the *call* to them on the caller's own requirement: the facade forwarded, a2a proxied and ConnectMCPSession handed the stream to the SDK without ever reaching the gate when no labels were required. A caller could therefore waive the floor by staying silent -- the exact fail-open shape the floor exists to close. Ranking did not catch it, because gossip labels are self-asserted and an unlabelled remote is deliberately left "to the gate". Enforced now in two places: - The /sam/ egress proxy, so the floor holds at the chokepoint whatever the surface: a2a, the facade's forward, and an agent that skips both and dials /sam//... raw. Confinement must not depend on the agent's cooperation. required is nil there; the caller's own requirement, when present, is still enforced by the surface that parsed it. - ConnectMCPSession, which reaches peers over a libp2p stream and not the proxy: the gate now also runs when only the floor requires it. The facade's forward loop runs the gate too when only the floor demands it, so an ineligible provider is skipped in favour of the next instead of surfacing the chokepoint's 403, and a floor failure with no caller requirement is attributed to egress_floor_mismatch rather than the caller-facing label_unattested. The regression tests pin the whole path, not the gate function: a silent caller on the facade is still gated and fails closed without a seam; a raw /sam/ GET inside the floor passes and one pair short is 403 on attested facts; CallMCPTool with nil required_labels is held to the floor. All three fail on the previous commit. --- internal/node/mcp.go | 9 ++- internal/node/mcp_handlers_test.go | 64 +++++++++++++++++++ internal/node/openai_facade.go | 19 ++++-- internal/node/openai_facade_test.go | 82 ++++++++++++++++++++++++ internal/node/proxy_test.go | 98 +++++++++++++++++++++++++++++ internal/node/sidecar.go | 23 +++++++ 6 files changed, 287 insertions(+), 8 deletions(-) diff --git a/internal/node/mcp.go b/internal/node/mcp.go index 522ed749..414e64a4 100644 --- a/internal/node/mcp.go +++ b/internal/node/mcp.go @@ -240,10 +240,10 @@ func (s *agentMCPServers) forAgent(agentID string) *mcp.Server { return server } -// CallMCPTool opens a stream to a remote peer, performs the handshake, and calls a tool. // CallMCPTool opens a stream to a remote peer, performs the handshake, and calls a tool. // requiredLabels, when non-empty, fail-closed verifies the peer's control-plane-attested -// labels (see checkPeerLabels) before the tool is invoked; nil means no requirement. +// labels (see checkPeerLabels) before the tool is invoked; nil means no caller +// requirement, though the operator's egress floor, if configured, is still enforced. func (n *SamNode) CallMCPTool(ctx context.Context, targetPeer peer.ID, toolName string, params any, requiredLabels map[string]string) (*mcp.CallToolResult, error) { var res *mcp.CallToolResult var err error @@ -378,7 +378,10 @@ func (n *SamNode) ConnectMCPSession(ctx context.Context, targetPeer peer.ID, tar return nil, nil, fmt.Errorf("%w by %s: %s", ErrAuthRejected, targetPeer, resp.Error) } - if len(requiredLabels) > 0 { + // The gate runs when the caller requires labels or when the operator's + // egress floor does: a caller that requires nothing is still held to the + // floor (checkPeerLabels ANDs both). + if len(requiredLabels) > 0 || len(n.egressFloor()) > 0 { if err := n.checkPeerLabels(resp.Biscuit, targetPeer, requiredLabels); err != nil { cleanup() return nil, nil, err diff --git a/internal/node/mcp_handlers_test.go b/internal/node/mcp_handlers_test.go index 4caa6195..345b624a 100644 --- a/internal/node/mcp_handlers_test.go +++ b/internal/node/mcp_handlers_test.go @@ -744,6 +744,70 @@ func TestCallMCPTool_LabelEnforcement(t *testing.T) { } } +// The operator's egress floor gates MCP egress even when the caller supplies no +// required_labels: silence must not waive the floor (see #385). +func TestCallMCPTool_EgressFloorEnforcement(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + nodeA, cleanupA := startBareNode(t, ctx) + defer cleanupA() + nodeB, cleanupB := startBareNode(t, ctx) + defer cleanupB() + + if err := nodeA.Host.Connect(ctx, peer.AddrInfo{ID: nodeB.Host.ID(), Addrs: nodeB.Host.Addrs()}); err != nil { + t.Fatalf("connect: %v", err) + } + + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("gen root key: %v", err) + } + + // nodeA authenticates to nodeB as an unrestricted caller. + if err := buildAndSaveBiscuit(nodeA, rootPriv); err != nil { + t.Fatalf("buildAndSaveBiscuit: %v", err) + } + nodeB.keysMu.Lock() + nodeB.trustedKeys = append(nodeB.trustedKeys, TrustedKey{Key: rootPub, ReceivedAt: time.Now()}) + nodeB.keysMu.Unlock() + + // nodeB attests region=eu-de and nothing else. + nodeBIdentity, err := identity.MintBootstrapBiscuitToken(rootPriv, nodeB.Host.ID(), api.RoleNode, time.Now().Add(time.Hour), nil, map[string]string{"region": "eu-de"}) + if err != nil { + t.Fatalf("mint nodeB identity: %v", err) + } + nodeB.SetIdentityCache(nodeBIdentity) + nodeA.keysMu.Lock() + nodeA.trustedKeys = append(nodeA.trustedKeys, TrustedKey{Key: rootPub, ReceivedAt: time.Now()}) + nodeA.keysMu.Unlock() + + hostedSrv := httptest.NewServer(newFakeMCPHandler(t, []*mcp.Tool{ + {Name: "review_pr", Description: "Run a code review", InputSchema: map[string]any{"type": "object"}}, + })) + defer hostedSrv.Close() + + if err := nodeB.RegisterService(ctx, &api.RegisterServiceRequest{ + Service: &api.ServiceInfo{Type: api.ServiceType_SERVICE_TYPE_MCP, Name: "code-reviewer"}, + Backend: &api.RegisterServiceRequest_TargetUrl{TargetUrl: hostedSrv.URL}, + }); err != nil { + t.Fatalf("RegisterService: %v", err) + } + defer func() { _ = nodeB.UnregisterService(ctx, "code-reviewer") }() + + // Floor satisfied by nodeB's attestation; the caller requires nothing. + nodeA.nodeConfig = &NodeConfigComplete{EgressRequireLabels: map[string]string{"region": "eu-de"}} + if _, err := nodeA.CallMCPTool(ctx, nodeB.Host.ID(), "mcp://code-reviewer/review_pr", map[string]any{}, nil); err != nil { + t.Fatalf("CallMCPTool inside the floor should succeed: %v", err) + } + + // Floor nodeB does not attest; the caller staying silent must not waive it. + nodeA.nodeConfig = &NodeConfigComplete{EgressRequireLabels: map[string]string{"jurisdiction": "eu"}} + if _, err := nodeA.CallMCPTool(ctx, nodeB.Host.ID(), "mcp://code-reviewer/review_pr", map[string]any{}, nil); err == nil { + t.Fatal("CallMCPTool outside the floor with no caller requirement must fail") + } +} + func TestNewMCPHandler_RegistersFindRemoteTools(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() diff --git a/internal/node/openai_facade.go b/internal/node/openai_facade.go index a00eb112..dee7b216 100644 --- a/internal/node/openai_facade.go +++ b/internal/node/openai_facade.go @@ -410,8 +410,10 @@ func (f *openAIFacade) handleCompletions(w http.ResponseWriter, r *http.Request) } else { // Labels ranked this provider; the label gate is the enforcement // point: the provider's biscuit must attest the requirement before - // the request body leaves this node. - if len(requiredLabels) > 0 { + // the request body leaves this node. The gate also runs when only + // the operator's floor requires it — a caller that asked for + // nothing is still held to the floor (VerifyPeerLabels ANDs both). + if len(requiredLabels) > 0 || len(f.floor()) > 0 { if f.verifyPeerLabels == nil { recordFacadeRejection(reasonLabelUnattested) writeOpenAIError(w, http.StatusServiceUnavailable, "label_unattested", @@ -421,9 +423,16 @@ func (f *openAIFacade) handleCompletions(w http.ResponseWriter, r *http.Request) // No backoff on failure: the verdict is requirement-scoped, // the provider stays eligible for unconstrained requests. if err := f.verifyPeerLabels(r.Context(), p.peerID, requiredLabels); err != nil { - recordFacadeRejection(reasonLabelUnattested) - logger.Warnf("[OpenAIFacade] provider peer=%q service=%q failed label attestation for %v: %v; trying next", - p.peerID, p.service, requiredLabels, err) + // With no caller requirement the only thing that can have + // failed is the floor; attribute it so the operator can + // tell their floor apart from a caller's requirement. + if len(requiredLabels) == 0 { + recordFacadeRejection(reasonEgressFloorMismatch) + } else { + recordFacadeRejection(reasonLabelUnattested) + } + logger.Warnf("[OpenAIFacade] provider peer=%q service=%q failed label attestation for %v (egress floor %v): %v; trying next", + p.peerID, p.service, requiredLabels, f.floor(), err) continue } } diff --git a/internal/node/openai_facade_test.go b/internal/node/openai_facade_test.go index 956c4caf..7fd8e3fb 100644 --- a/internal/node/openai_facade_test.go +++ b/internal/node/openai_facade_test.go @@ -896,3 +896,85 @@ func TestRankProvidersEnforcesEgressFloor(t *testing.T) { } }) } + +// Ranking is a hint; the gate on the forward path is the enforcement. Gossip is +// unauthenticated, so an unlabelled remote survives ranking and the biscuit +// gate is the only check left before the body leaves the node — it must run +// even when the caller required nothing, or the floor is waived by silence. +func TestFacade_Completions_FloorGatesSilentCaller(t *testing.T) { + newFloorFacade := func() *openAIFacade { + f := newTestFacade() + f.egressFloor = func() map[string]string { return map[string]string{"jurisdiction": "eu"} } + f.viewProviders = func(string) []modelProvider { + return []modelProvider{{peerID: "peerX", service: "srv"}} + } + return f + } + silentRequest := func() *http.Request { + // No X-Sam-Required-Labels header: the caller requires nothing. + return httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"m1"}`)) + } + + t.Run("an unattested floor blocks the forward", func(t *testing.T) { + f := newFloorFacade() + var gateRequired map[string]string + gateCalled := false + f.verifyPeerLabels = func(_ context.Context, _ string, required map[string]string) error { + gateCalled = true + gateRequired = required + return fmt.Errorf("floor not attested") + } + forwarded := false + f.forward = http.HandlerFunc(func(http.ResponseWriter, *http.Request) { forwarded = true }) + + rec := httptest.NewRecorder() + f.handleCompletions(rec, silentRequest()) + + if !gateCalled { + t.Fatal("the gate must run for a caller that required nothing") + } + if gateRequired != nil { + t.Errorf("the caller required nothing, so the gate must see required=nil, got %v", gateRequired) + } + if forwarded { + t.Error("the body must not leave the node when the floor is unattested") + } + if rec.Code != http.StatusServiceUnavailable { + t.Errorf("status = %d, want %d", rec.Code, http.StatusServiceUnavailable) + } + }) + + t.Run("an attested floor forwards", func(t *testing.T) { + f := newFloorFacade() + f.verifyPeerLabels = func(context.Context, string, map[string]string) error { return nil } + forwarded := false + f.forward = http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + forwarded = true + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + }) + + rec := httptest.NewRecorder() + f.handleCompletions(rec, silentRequest()) + + if rec.Code != http.StatusOK || !forwarded { + t.Errorf("an attested floor must not block: status = %d, forwarded = %v", rec.Code, forwarded) + } + }) + + t.Run("a floor with no gate seam fails closed", func(t *testing.T) { + f := newFloorFacade() // verifyPeerLabels stays nil + forwarded := false + f.forward = http.HandlerFunc(func(http.ResponseWriter, *http.Request) { forwarded = true }) + + rec := httptest.NewRecorder() + f.handleCompletions(rec, silentRequest()) + + if forwarded { + t.Error("no gate available must mean no egress, not ungated egress") + } + if rec.Code != http.StatusServiceUnavailable { + t.Errorf("status = %d, want %d", rec.Code, http.StatusServiceUnavailable) + } + }) +} diff --git a/internal/node/proxy_test.go b/internal/node/proxy_test.go index 1bc33ef2..afa3c267 100644 --- a/internal/node/proxy_test.go +++ b/internal/node/proxy_test.go @@ -31,6 +31,7 @@ import ( "time" "github.com/google/sam/api" + "github.com/google/sam/internal/identity" "github.com/libp2p/go-libp2p/core/crypto" "github.com/libp2p/go-libp2p/core/peer" ) @@ -844,3 +845,100 @@ func TestDatapathHeadersAndRoutingTable(t *testing.T) { }) } } + +// The operator's egress floor holds at the /sam/ chokepoint itself, so an agent +// that skips the facade and dials /sam//... raw is still gated: the +// provider's biscuit must attest every pair of the floor before anything is +// forwarded, whatever the caller did or did not ask for (see #385). +func TestEgressProxyEnforcesFloor(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + nodeA, cleanupA := startBareNode(t, ctx) // provider + defer cleanupA() + nodeB, cleanupB := startBareNode(t, ctx) // egress side, holds the floor + defer cleanupB() + + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("gen root key: %v", err) + } + + // nodeB authenticates to nodeA as an unrestricted caller. + if err := buildAndSaveBiscuit(nodeB, rootPriv); err != nil { + t.Fatalf("buildAndSaveBiscuit: %v", err) + } + nodeA.keysMu.Lock() + nodeA.trustedKeys = append(nodeA.trustedKeys, TrustedKey{Key: rootPub, ReceivedAt: time.Now()}) + nodeA.keysMu.Unlock() + + // nodeA attests jurisdiction=eu and nothing else. + nodeAIdentity, err := identity.MintBootstrapBiscuitToken(rootPriv, nodeA.Host.ID(), api.RoleNode, time.Now().Add(time.Hour), nil, map[string]string{"jurisdiction": "eu"}) + if err != nil { + t.Fatalf("mint nodeA identity: %v", err) + } + nodeA.SetIdentityCache(nodeAIdentity) + nodeB.keysMu.Lock() + nodeB.trustedKeys = append(nodeB.trustedKeys, TrustedKey{Key: rootPub, ReceivedAt: time.Now()}) + nodeB.keysMu.Unlock() + + if err := nodeB.Host.Connect(ctx, peer.AddrInfo{ID: nodeA.Host.ID(), Addrs: nodeA.Host.Addrs()}); err != nil { + t.Fatal(err) + } + + const expectedBody = `{"status":"floored"}` + dummyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(expectedBody)) + })) + defer dummyServer.Close() + + serviceInfo := &api.ServiceInfo{Type: api.ServiceType_SERVICE_TYPE_MCP, Name: "dummy-tool"} + targetURL, _ := url.Parse(dummyServer.URL) + nodeA.services.insertService(&testService{ + info: serviceInfo, + handler: httputil.NewSingleHostReverseProxy(targetURL), + }) + + proxyServer := httptest.NewServer(createEgressProxy(nodeB)) + defer proxyServer.Close() + // The caller stays silent: no X-Sam-Required-Labels anywhere. + rawURL := fmt.Sprintf("%s/sam/%s/mcp/dummy-tool/api/v1/test", proxyServer.URL, nodeA.Host.ID()) + + get := func() *http.Response { + t.Helper() + resp, err := http.Get(rawURL) + if err != nil { + t.Fatal(err) + } + return resp + } + + // nodeA attests the whole floor: egress proceeds. + nodeB.nodeConfig = &NodeConfigComplete{EgressRequireLabels: map[string]string{"jurisdiction": "eu"}} + var resp *http.Response + for i := 0; i < 3; i++ { + resp = get() + if resp.StatusCode == http.StatusOK { + break + } + _ = resp.Body.Close() + time.Sleep(time.Second) + } + body, err := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusOK || string(body) != expectedBody { + t.Fatalf("egress inside the floor must pass: status %d, body %q", resp.StatusCode, body) + } + + // One pair short of the floor: refused at the chokepoint, nothing forwarded. + nodeB.nodeConfig = &NodeConfigComplete{EgressRequireLabels: map[string]string{"jurisdiction": "eu", "compliance": "gdpr"}} + resp = get() + _ = resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("egress outside the floor must be 403 at the chokepoint, got %d", resp.StatusCode) + } +} diff --git a/internal/node/sidecar.go b/internal/node/sidecar.go index c2429858..2a0e2e3b 100644 --- a/internal/node/sidecar.go +++ b/internal/node/sidecar.go @@ -711,6 +711,29 @@ func createEgressProxy(node *SamNode) http.Handler { return } + // The operator's egress floor (egress.require_labels) holds at this + // chokepoint whatever the surface, so an agent that skips the facade + // and dials /sam//... raw is gated the same way. required is nil: + // any caller requirement was already enforced by the surface that + // parsed it; the floor is what a silent caller cannot waive. + if floor := node.egressFloor(); len(floor) > 0 { + route, ok := parseEgressRoute(r.URL.Path) + if !ok { + http.Error(w, "Forbidden: egress floor in force and request names no peer", http.StatusForbidden) + return + } + pid, err := peer.Decode(route.peerID) + if err != nil { + http.Error(w, "Bad Request: invalid peer ID", http.StatusBadRequest) + return + } + if err := node.VerifyPeerLabels(r.Context(), pid, nil); err != nil { + logger.Warnf("[Egress] floor gate refused egress to %s: %v", route.peerID, err) + http.Error(w, "Forbidden: provider does not attest the egress floor", http.StatusForbidden) + return + } + } + r.Header.Set(api.HeaderSamBiscuit, base64.StdEncoding.EncodeToString(biscuitBytes)) // Forwarded, not stripped: the agent claim is what lets the peer at the From 90112da9e7ff7127c5e1f7aba558df30b5e24b05 Mon Sep 17 00:00:00 2001 From: Antonio Ojea Date: Tue, 15 Sep 2026 20:35:47 +0000 Subject: [PATCH 5/6] mobile: let the operator set the egress floor on a phone node The Android node reuses internal/node wholesale -- NewSamNode, the sidecar, the /sam/ egress proxy -- so every enforcement point of the egress floor already applies to it. What it could not do was *have* a floor: MobileConfig carried labels, services and attenuation but no egress block, so EgressRequireLabels was always nil on mobile and the sovereignty boundary could not be drawn on the one node class most likely to roam across jurisdictions. MobileConfig gains the egress block, passed through CompleteNodeConfig like the rest, so a malformed floor fails StartNode the same way it fails a desktop node at startup. The decode test pins the JSON spelling (requireLabels, matched case-insensitively to the Go field, not yaml's require_labels), because api.Egress carries only yaml tags and a silently missed key would start the node with no floor while the operator believes one is set -- the same hazard the attenuation decode test already pins. --- mobile/sam-node-ffi/ffi/ffi.go | 5 +++++ mobile/sam-node-ffi/ffi/ffi_test.go | 15 +++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/mobile/sam-node-ffi/ffi/ffi.go b/mobile/sam-node-ffi/ffi/ffi.go index e0082d5a..bdbafe2f 100644 --- a/mobile/sam-node-ffi/ffi/ffi.go +++ b/mobile/sam-node-ffi/ffi/ffi.go @@ -67,6 +67,10 @@ type MobileConfig struct { // Local attenuation, same shape as the config file's block. Go matches // these keys to the yaml-tagged fields case-insensitively. Attenuation api.Attenuation `json:"attenuation"` + // Egress mirrors the config file's egress block: the operator's floor on + // the peers this node calls (api.Egress). JSON spelling is requireLabels, + // matched case-insensitively to the Go field, not the yaml require_labels. + Egress api.Egress `json:"egress"` } // MobileService is one statically declared service. @@ -191,6 +195,7 @@ func StartNode(configJSON string) error { Attenuation: config.Attenuation, Services: services, Labels: config.Labels, + Egress: config.Egress, }) if err != nil { _ = store.Close() diff --git a/mobile/sam-node-ffi/ffi/ffi_test.go b/mobile/sam-node-ffi/ffi/ffi_test.go index 52138435..23c48807 100644 --- a/mobile/sam-node-ffi/ffi/ffi_test.go +++ b/mobile/sam-node-ffi/ffi/ffi_test.go @@ -125,6 +125,8 @@ func TestMobileFFILifecycle(t *testing.T) { ApiToken: "test-token", AllowLoopback: true, Labels: map[string]string{"region": "eu-west-1"}, + // A valid floor must not stop startup; enforcement is internal/node's. + Egress: api.Egress{RequireLabels: map[string]string{"jurisdiction": "eu"}}, } cfgBytes, _ := json.Marshal(cfg) @@ -200,6 +202,19 @@ func TestMobileConfigDecodesAttenuation(t *testing.T) { } } +// Same pin for the egress floor: it rides the yaml-tagged api.Egress, so the +// JSON spelling is requireLabels. A silent miss here would start the node +// with no floor while the operator believes one is set. +func TestMobileConfigDecodesEgressFloor(t *testing.T) { + var config MobileConfig + if err := json.Unmarshal([]byte(`{"egress":{"requireLabels":{"jurisdiction":"eu"}}}`), &config); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if config.Egress.RequireLabels["jurisdiction"] != "eu" { + t.Fatalf("got %+v, want the floor pair decoded", config.Egress) + } +} + // Re-enrollment buys a JWT with the stored refresh token and re-attests the // new labels under the same PeerID, with no browser involved. func TestReEnrollNodeUsesStoredRefreshToken(t *testing.T) { From 1b3662ff8a5851854c0f3c50fa9152f6ae0e42f7 Mon Sep 17 00:00:00 2001 From: Antonio Ojea Date: Tue, 15 Sep 2026 20:46:52 +0000 Subject: [PATCH 6/6] node: canonicalize the peer ID at the egress floor gate A peer ID has more than one valid spelling (base58 multihash and CID), and the floor gate decoded the path's segment for its verdict while letting the original spelling travel on to the proxy Director and the log line -- so the gate and the dial could name the same peer in different forms. Per the style guide, peer IDs are canonicalized at the boundary: the gate now rewrites the path segment to the canonical form when the caller spelled it otherwise, and logs the same. The floor test gains the alias case: a CID-spelled peer inside the floor passes end to end. --- internal/node/proxy_test.go | 16 ++++++++++++++++ internal/node/sidecar.go | 12 +++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/internal/node/proxy_test.go b/internal/node/proxy_test.go index afa3c267..b17885fd 100644 --- a/internal/node/proxy_test.go +++ b/internal/node/proxy_test.go @@ -934,6 +934,22 @@ func TestEgressProxyEnforcesFloor(t *testing.T) { t.Fatalf("egress inside the floor must pass: status %d, body %q", resp.StatusCode, body) } + // A non-canonical spelling of the same peer (CID form) is canonicalized at + // the gate boundary, so the verdict and the dial name the same peer. + cidURL := fmt.Sprintf("%s/sam/%s/mcp/dummy-tool/api/v1/test", proxyServer.URL, peer.ToCid(nodeA.Host.ID())) + resp, err = http.Get(cidURL) + if err != nil { + t.Fatal(err) + } + body, err = io.ReadAll(resp.Body) + _ = resp.Body.Close() + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusOK || string(body) != expectedBody { + t.Fatalf("a CID-spelled peer inside the floor must pass: status %d, body %q", resp.StatusCode, body) + } + // One pair short of the floor: refused at the chokepoint, nothing forwarded. nodeB.nodeConfig = &NodeConfigComplete{EgressRequireLabels: map[string]string{"jurisdiction": "eu", "compliance": "gdpr"}} resp = get() diff --git a/internal/node/sidecar.go b/internal/node/sidecar.go index 2a0e2e3b..c8a2ffa6 100644 --- a/internal/node/sidecar.go +++ b/internal/node/sidecar.go @@ -727,8 +727,18 @@ func createEgressProxy(node *SamNode) http.Handler { http.Error(w, "Bad Request: invalid peer ID", http.StatusBadRequest) return } + // The verdict is for the canonical peer, so the dial must name the + // same form: rewrite the segment the Director will re-parse rather + // than let a non-canonical spelling travel past the gate. + if canonical := pid.String(); route.peerID != canonical { + parts := strings.SplitN(r.URL.Path, "/", 6) + if len(parts) >= 3 { + parts[2] = canonical + r.URL.Path = strings.Join(parts, "/") + } + } if err := node.VerifyPeerLabels(r.Context(), pid, nil); err != nil { - logger.Warnf("[Egress] floor gate refused egress to %s: %v", route.peerID, err) + logger.Warnf("[Egress] floor gate refused egress to %s: %v", pid, err) http.Error(w, "Forbidden: provider does not attest the egress floor", http.StatusForbidden) return }