From 6443c66e666c2f9998b6ef564e7cf1b9bc53bd02 Mon Sep 17 00:00:00 2001 From: Antonio Ojea Date: Mon, 28 Sep 2026 15:39:18 +0000 Subject: [PATCH] labels: a caller's requirement is a conjunction, as the egress floor is `X-Sam-Required-Labels: k=v,k2=v2` and the SDKs' `requiredLabels` / `required_labels` now refuse a provider unless its credential attests every listed pair. Until now they accepted any one pair, while `egress.require_labels` required all of them: one syntax, two meanings. The disjunction dates from `X-Sam-Required-Region`, which took a list of regions (`region("EU") or region("EU-DE")`). Generalised to a map with one value per key it can no longer spell the alternative it was made for (`region=eu-west-1` or `region=eu-central-1` is a `400 duplicate label key`), and the intent people do write with two pairs, `region=eu, compliance=gdpr`, was silently widened to "either": a provider attesting only `region=eu` passed, with no error. Every label selector a reader is likely to know (Kubernetes, Prometheus, Istio, SPIRE, Docker, Nomad, AWS IAM) reads a map of pairs as a conjunction and spells alternatives for one key explicitly. `api.LabelCheck` compiles `check if label(k1, v1), label(k2, v2)`; `LabelFloorCheck` is gone, the floor uses the same function. `LabelsSatisfyFloor` / `LabelsContradictFloor` become `LabelsSatisfy` / `LabelsContradict` and serve the requirement in `rankProviders` the way they served the floor: a local must declare every pair, a remote is only dropped on a conflicting claim and the gate decides on attested facts. `checkPeerLabels` keeps two checks so no caller input reaches the floor. A repeated key (`region=a,region=b`) stays rejected and is reserved for per-key alternatives later; adding it then breaks no client. --- agents/skills/sam-a2a-bridge/SKILL.md | 5 +- agents/skills/sam-mesh/SKILL.md | 8 +- api/datalog.go | 85 ++++++----------- api/datalog_test.go | 91 ++++++++----------- api/network.go | 4 +- api/policy.go | 9 +- cmd/sam-a2a-bridge/a2a.go | 2 +- internal/node/config.go | 2 +- internal/node/labels_gate.go | 17 ++-- internal/node/labels_gate_test.go | 4 +- internal/node/mcp_handlers.go | 2 +- internal/node/openai_facade_test.go | 19 ++++ internal/node/openai_scorer.go | 67 ++++++-------- sdk/README.md | 38 ++++---- sdk/js/src/mcp.test.ts | 17 ++-- sdk/js/src/mcp.ts | 48 +++++----- sdk/python/src/agent_mesh/mcp_client.py | 37 ++++---- sdk/python/tests/test_mcp.py | 29 +++--- site/content/docs/concepts/authorization.md | 17 ++-- site/content/docs/concepts/boundaries.md | 2 +- site/content/docs/concepts/networking.md | 6 +- site/content/docs/guides/exposing-services.md | 5 +- site/content/docs/guides/native-sdks.md | 16 ++-- site/content/docs/reference/node-api.md | 4 +- site/content/docs/reference/node-config.md | 9 +- tests/integration/sdk_mesh_test.go | 30 +++--- 26 files changed, 273 insertions(+), 300 deletions(-) diff --git a/agents/skills/sam-a2a-bridge/SKILL.md b/agents/skills/sam-a2a-bridge/SKILL.md index 0d9b2bff..8e0c9f12 100644 --- a/agents/skills/sam-a2a-bridge/SKILL.md +++ b/agents/skills/sam-a2a-bridge/SKILL.md @@ -73,8 +73,9 @@ input schema) and `file_path` attachments appropriately. under the download directory. - **Data residency**: when the task involves data that must stay in a region or jurisdiction, set `required_labels` (comma-separated `key=value`, e.g. - `region=eu-west-1`). The local node then refuses fail-closed before any data - leaves it unless the peer's control-plane-attested labels match. Never drop + `region=eu-west-1`; several pairs must all be attested). The local node then + refuses fail-closed before any data leaves it unless the peer's + control-plane-attested labels match. Never drop or weaken `required_labels` to make a refused call succeed without the user's explicit approval — the refusal is the feature. diff --git a/agents/skills/sam-mesh/SKILL.md b/agents/skills/sam-mesh/SKILL.md index ef4d87bb..65a76766 100644 --- a/agents/skills/sam-mesh/SKILL.md +++ b/agents/skills/sam-mesh/SKILL.md @@ -218,10 +218,10 @@ The node exposes them through an OpenAI-compatible facade on its own address: - `GET /v1/models` lists the models reachable across the mesh. - `POST /v1/chat/completions` routes to a provider of the requested `model`, preferring a local one, and fails over between providers. -- Add `X-Sam-Required-Labels: key=value` (comma-separated, any-of) to accept - only providers whose labels the control plane attested, for example - `region=eu`. Enforcement is fail-closed: unattested providers are rejected - before any request data leaves the node. +- Add `X-Sam-Required-Labels: key=value` (comma-separated; every pair must + be attested) to accept only providers whose labels the control plane + attested, for example `region=eu`. Enforcement is fail-closed: unattested + providers are rejected before any request data leaves the node. To pin one specific provider instead of letting the facade choose, call `discover_remote_services` with `{"type":"inference"}` and send the request to diff --git a/api/datalog.go b/api/datalog.go index e7fbf150..b2ff09ba 100644 --- a/api/datalog.go +++ b/api/datalog.go @@ -737,47 +737,22 @@ func LabelFacts(labels map[string]string) []biscuit.Fact { } // LabelCheck compiles a required label set (canonical, pre-validated with -// ValidateLabels) into a single fail-closed check satisfied when the token -// carries any of them: `check if label("region", "us-east-1") or -// label("team", "platform")`. -func LabelCheck(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, " 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")`. +// ValidateLabels) 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. +// The same rule serves a caller's requirement (X-Sam-Required-Labels, an +// SDK's required labels) and an operator's egress floor (Egress.RequireLabels). +// A map gives one value per key, so neither can spell an alternative, and +// listing several pairs narrows the set of acceptable peers, as it does in +// every label selector a reader is likely to know. A disjunction would let +// a peer attesting the most permissive pair stand in for the rest, and a +// caller who read the list as a conjunction would send data to a peer that +// lacks a property they required, with no error to tell them. // -// 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) { +// A requirement and a floor are combined by adding one check each to the +// authorizer, which ANDs them; neither needs to know about the other. +func LabelCheck(required map[string]string) (biscuit.Check, error) { if len(required) == 0 { return biscuit.Check{}, fmt.Errorf("no required labels") } @@ -800,13 +775,13 @@ func LabelFloorCheck(required map[string]string) (biscuit.Check, error) { 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 { +// LabelsSatisfy reports whether claimed carries every pair of required. It is +// the non-attested counterpart of LabelCheck, 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 requirement is satisfied by +// anything, so callers may pass one unconditionally. +func LabelsSatisfy(required, claimed map[string]string) bool { + for k, v := range required { if claimed[k] != v { return false } @@ -814,17 +789,17 @@ func LabelsSatisfyFloor(floor, claimed map[string]string) bool { return true } -// LabelsContradictFloor reports whether claimed states a *different* value for -// some key the floor requires. +// LabelsContradict reports whether claimed states a *different* value for +// some key required names. // // 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 { +// LabelsSatisfy. Gossiped claims are a discovery hint and may carry only part +// of what a peer attests, so a peer silent on one pair may still satisfy all +// of them 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 LabelsContradict(required, claimed map[string]string) bool { + for k, v := range required { if got, stated := claimed[k]; stated && got != v { return true } diff --git a/api/datalog_test.go b/api/datalog_test.go index cee99186..f94485b9 100644 --- a/api/datalog_test.go +++ b/api/datalog_test.go @@ -573,12 +573,6 @@ func TestLabelFactsAndCheck(t *testing.T) { if facts := LabelFacts(nil); facts != nil { t.Errorf("LabelFacts(nil) = %v, want nil", facts) } - if _, err := LabelCheck(nil); err == nil { - t.Error("LabelCheck(nil): expected error, got nil") - } - if _, err := LabelCheck(map[string]string{"region": "bad,value"}); err == nil { - t.Error("LabelCheck(invalid value): expected error, got nil") - } tests := []struct { name string @@ -587,8 +581,9 @@ func TestLabelFactsAndCheck(t *testing.T) { expectAllow bool }{ {"exact match", map[string]string{"region": "us-east-1"}, map[string]string{"region": "us-east-1"}, true}, - {"any-of requirement", map[string]string{"region": "us-east-1"}, map[string]string{"region": "eu", "team": "us-east-1"}, false}, - {"any-of requirement matches one key", map[string]string{"region": "us-east-1", "team": "platform"}, map[string]string{"region": "eu", "team": "platform"}, true}, + {"every pair present", map[string]string{"region": "us-east-1", "team": "platform", "tier": "gold"}, map[string]string{"region": "us-east-1", "team": "platform"}, true}, + {"one pair of two missing", map[string]string{"region": "us-east-1"}, map[string]string{"region": "us-east-1", "team": "platform"}, false}, + {"one pair of two wrong", map[string]string{"region": "us-east-1", "team": "platform"}, map[string]string{"region": "eu", "team": "platform"}, false}, {"case-sensitive value mismatch", map[string]string{"region": "us-east-1"}, map[string]string{"region": "US-EAST-1"}, false}, {"no built-in hierarchy: coarser requirement does not match a finer claim", map[string]string{"region": "us-east-1"}, map[string]string{"region": "us"}, false}, {"disjoint labels", map[string]string{"region": "us-east-1"}, map[string]string{"region": "eu-west-1"}, false}, @@ -629,48 +624,38 @@ 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) { +// A requirement of several pairs is a conjunction, pinned on the compiled +// shape rather than on the rendered string: one query body carrying every +// predicate, not one body per pair. +func TestLabelCheckIsConjunction(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) + check, 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)) + if len(check.Queries) != 1 { + t.Fatalf("a requirement must compile to a single conjunctive body, got %d alternatives", len(check.Queries)) + } + if got := len(check.Queries[0].Body); got != len(both) { + t.Errorf("the body carries %d predicates, want all %d", got, len(both)) } } -func TestLabelFloorCheckRejectsBadInput(t *testing.T) { - if _, err := LabelFloorCheck(nil); err == nil { - t.Error("LabelFloorCheck(nil): expected error, got nil") +func TestLabelCheckRejectsBadInput(t *testing.T) { + if _, err := LabelCheck(nil); err == nil { + t.Error("LabelCheck(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 := LabelCheck(map[string]string{"region": "bad,value"}); err == nil { + t.Error("LabelCheck(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") + if _, err := LabelCheck(map[string]string{"bad key!": "v"}); err == nil { + t.Error("LabelCheck(invalid key): expected error, got nil") } } -func TestLabelsSatisfyFloor(t *testing.T) { - floor := map[string]string{"jurisdiction": "eu", "compliance": "gdpr"} +func TestLabelsSatisfy(t *testing.T) { + required := map[string]string{"jurisdiction": "eu", "compliance": "gdpr"} tests := []struct { name string claimed map[string]string @@ -682,39 +667,39 @@ func TestLabelsSatisfyFloor(t *testing.T) { {"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) + if got := LabelsSatisfy(required, tt.claimed); got != tt.want { + t.Errorf("%s: LabelsSatisfy = %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") + // An empty requirement constrains nothing, so callers can pass it unconditionally. + if !LabelsSatisfy(nil, nil) { + t.Error("an empty requirement 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"} +// The two predicates differ on one case, and it is the case that matters: a +// peer silent on a pair the requirement names. Gossip is partial, so silence +// must not read as failure before the gate has seen attested facts. +func TestLabelsContradictTreatsSilenceAsUnknown(t *testing.T) { + required := 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 LabelsContradict(required, partial) { + t.Error("a claim silent on one pair does not contradict the requirement") } - if LabelsSatisfyFloor(floor, partial) { + if LabelsSatisfy(required, partial) { t.Error("but it does not satisfy it either") } conflicting := map[string]string{"jurisdiction": "us"} - if !LabelsContradictFloor(floor, conflicting) { + if !LabelsContradict(required, conflicting) { t.Error("a different value for a required key is a contradiction") } - if LabelsContradictFloor(floor, nil) { + if LabelsContradict(required, nil) { t.Error("no claims at all cannot contradict anything") } - if LabelsContradictFloor(floor, map[string]string{"jurisdiction": "eu", "compliance": "gdpr"}) { + if LabelsContradict(required, map[string]string{"jurisdiction": "eu", "compliance": "gdpr"}) { t.Error("a fully satisfying claim must not read as a contradiction") } } diff --git a/api/network.go b/api/network.go index 2a2b541e..1e2609e1 100644 --- a/api/network.go +++ b/api/network.go @@ -181,8 +181,8 @@ const ( HeaderSamNoTrailingSlash = "X-Sam-No-Trailing-Slash" // HeaderSamRequiredLabels constrains an inference request on the sidecar's - // OpenAI-compatible endpoints (/v1/*) to providers attested with any of a - // comma-separated list of "key=value" label requirements (see + // OpenAI-compatible endpoints (/v1/*) to providers attested with every + // pair of a comma-separated list of "key=value" label requirements (see // api/labels.go and LabelCheck); invalid entries are rejected with HTTP // 400. It can only narrow what mesh policy allows, never widen it. Absent // means any provider permitted by policy. diff --git a/api/policy.go b/api/policy.go index 61799679..6d0d9542 100644 --- a/api/policy.go +++ b/api/policy.go @@ -77,11 +77,10 @@ type Egress struct { // 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. + // Every pair must hold, as for a caller's requirement (see LabelCheck). 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. diff --git a/cmd/sam-a2a-bridge/a2a.go b/cmd/sam-a2a-bridge/a2a.go index 0964998a..92d784fc 100644 --- a/cmd/sam-a2a-bridge/a2a.go +++ b/cmd/sam-a2a-bridge/a2a.go @@ -120,7 +120,7 @@ type sendAgentTaskParams struct { Data map[string]any `json:"data,omitempty" jsonschema:"Structured JSON payload sent to the agent as an A2A DataPart"` FilePath string `json:"file_path,omitempty" jsonschema:"Local file to attach; sent to the agent as bytes, max 5 MB"` FileName string `json:"file_name,omitempty" jsonschema:"Name shown to the agent for the attached file (default: the file's base name)"` - RequiredLabels string `json:"required_labels,omitempty" jsonschema:"Comma-separated key=value labels the provider must have attested (e.g. region=eu-west-1); the local node refuses fail-closed before any data leaves it"` + RequiredLabels string `json:"required_labels,omitempty" jsonschema:"Comma-separated key=value labels the provider must all have attested (e.g. region=eu-west-1,compliance=gdpr); the local node refuses fail-closed before any data leaves it"` ContextID string `json:"context_id,omitempty" jsonschema:"Continue an existing conversation context"` TaskID string `json:"task_id,omitempty" jsonschema:"Reply into an existing task, e.g. one in state input-required"` } diff --git a/internal/node/config.go b/internal/node/config.go index b86f1932..8264fc82 100644 --- a/internal/node/config.go +++ b/internal/node/config.go @@ -98,7 +98,7 @@ func CompleteNodeConfig(config api.NodeConfig) (*NodeConfigComplete, error) { 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 { + if _, err := api.LabelCheck(config.Egress.RequireLabels); err != nil { return nil, fmt.Errorf("invalid egress.require_labels: %w", err) } complete.EgressRequireLabels = config.Egress.RequireLabels diff --git a/internal/node/labels_gate.go b/internal/node/labels_gate.go index 8fb449a9..ebb41c8b 100644 --- a/internal/node/labels_gate.go +++ b/internal/node/labels_gate.go @@ -137,8 +137,8 @@ func (n *SamNode) egressFloor() map[string]string { } // VerifyPeerLabels ensures the peer holds control-plane-attested labels -// satisfying the caller's requirement (any one pair) and the operator's egress -// floor (every pair). Positive verdicts are cached. +// satisfying every pair of the caller's requirement and every pair of the +// operator's egress floor. 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 @@ -198,10 +198,9 @@ func (n *SamNode) checkPeerLabels(providerBiscuit []byte, peerID peer.ID, requir if err != nil { return fmt.Errorf("provider %s authorizer instantiation failed: %w", peerID, 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. + // Two checks, not one merged set: the floor is the operator's and no + // caller input reaches it. Both are conjunctions, and the authorizer ANDs + // them, so the peer has to attest every pair of each. if len(required) > 0 { check, err := api.LabelCheck(required) if err != nil { @@ -210,7 +209,7 @@ func (n *SamNode) checkPeerLabels(providerBiscuit []byte, peerID peer.ID, requir authorizer.AddCheck(check) } if len(floor) > 0 { - check, err := api.LabelFloorCheck(floor) + check, err := api.LabelCheck(floor) if err != nil { return err } @@ -219,9 +218,9 @@ func (n *SamNode) checkPeerLabels(providerBiscuit []byte, peerID peer.ID, requir 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 does not attest every label of 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 fmt.Errorf("provider %s does not attest every label of %v: %w", peerID, required, err) } return nil } diff --git a/internal/node/labels_gate_test.go b/internal/node/labels_gate_test.go index e462b34b..6484ca96 100644 --- a/internal/node/labels_gate_test.go +++ b/internal/node/labels_gate_test.go @@ -74,7 +74,9 @@ func TestCheckPeerLabels(t *testing.T) { expectErr bool }{ {"exact match", mint(cpPriv, providerPeer, map[string]string{"region": "us-east-1"}), map[string]string{"region": "us-east-1"}, false}, - {"any-of requirement matches one key", mint(cpPriv, providerPeer, map[string]string{"region": "na-us", "team": "platform"}), map[string]string{"region": "eu", "team": "platform"}, false}, + {"every pair of two attested", mint(cpPriv, providerPeer, map[string]string{"region": "na-us", "team": "platform"}), map[string]string{"region": "na-us", "team": "platform"}, false}, + {"one pair of two wrong fails", mint(cpPriv, providerPeer, map[string]string{"region": "na-us", "team": "platform"}), map[string]string{"region": "eu", "team": "platform"}, true}, + {"one pair of two missing fails", mint(cpPriv, providerPeer, map[string]string{"region": "na-us"}), map[string]string{"region": "na-us", "team": "platform"}, true}, {"no built-in hierarchy: coarser requirement fails a finer claim", mint(cpPriv, providerPeer, map[string]string{"region": "us-east-1"}), map[string]string{"region": "us"}, true}, {"disjoint labels fail", mint(cpPriv, providerPeer, map[string]string{"region": "na-us"}), map[string]string{"region": "eu"}, true}, {"unattested token fails closed", mint(cpPriv, providerPeer, nil), map[string]string{"region": "eu"}, true}, diff --git a/internal/node/mcp_handlers.go b/internal/node/mcp_handlers.go index ce61fd39..e52cd401 100644 --- a/internal/node/mcp_handlers.go +++ b/internal/node/mcp_handlers.go @@ -164,7 +164,7 @@ type CallRemoteToolParams struct { PeerID string `json:"peer_id" jsonschema:"The Peer ID of the target agent"` ToolName string `json:"tool_name" jsonschema:"The name of the server to call"` Arguments map[string]any `json:"arguments,omitempty" jsonschema:"Server arguments as a JSON object whose keys match the target server's input_schema. Call describe_remote_tool first to learn the schema."` - RequiredLabels string `json:"required_labels,omitempty" jsonschema:"Comma-separated key=value pairs (e.g. 'region=us-east-1,team=platform'). Fails closed: the call is rejected unless the peer attests any one of them. Empty means no requirement."` + RequiredLabels string `json:"required_labels,omitempty" jsonschema:"Comma-separated key=value pairs (e.g. 'region=us-east-1,team=platform'). Fails closed: the call is rejected unless the peer attests every one of them. Empty means no requirement."` } // handleCallRemoteTool implements the call_remote_tool tool. diff --git a/internal/node/openai_facade_test.go b/internal/node/openai_facade_test.go index 45b18366..9818ca6e 100644 --- a/internal/node/openai_facade_test.go +++ b/internal/node/openai_facade_test.go @@ -425,6 +425,25 @@ func TestRankProviders(t *testing.T) { } }) + t.Run("several pairs must all hold", func(t *testing.T) { + f := newTestFacade() + f.localLabels = func() map[string]string { return map[string]string{"region": "eu-de"} } + required := map[string]string{"region": "eu-de", "team": "platform"} + // A remote silent on one pair is not ruled out here: gossip is + // partial and the gate decides on attested facts. One that states a + // different value is. A local has no gate, so it must declare all of it. + silent := modelProvider{peerID: "peerSilent", service: "srv", labels: map[string]string{"region": "eu-de"}} + conflicting := modelProvider{peerID: "peerSRE", service: "srv", labels: map[string]string{"region": "eu-de", "team": "sre"}} + got := f.rankProviders([]modelProvider{silent, conflicting, local}, required) + if len(got) != 1 || got[0].peerID != "peerSilent" { + t.Fatalf("unexpected ranking under a two-pair requirement: %+v", got) + } + f.localLabels = func() map[string]string { return map[string]string{"region": "eu-de", "team": "platform"} } + if got := f.rankProviders([]modelProvider{local}, required); len(got) != 1 { + t.Fatalf("local declaring every pair should survive: %+v", got) + } + }) + t.Run("revoked peers are excluded", func(t *testing.T) { f := newTestFacade() f.isRevoked = func(peerID string) bool { return peerID == "peerUS" } diff --git a/internal/node/openai_scorer.go b/internal/node/openai_scorer.go index 055cee7d..227789f9 100644 --- a/internal/node/openai_scorer.go +++ b/internal/node/openai_scorer.go @@ -48,15 +48,21 @@ func (f *openAIFacade) floor() map[string]string { 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 { - for k, v := range required { - if claimed[k] == v { - return true - } +// outsideLabels reports whether a provider's claimed labels rule it out +// under required, the same way for a caller's requirement and for the +// operator's floor. A local is settled here for good: its labels are its own +// configuration, it has no Biscuit, and it never reaches the gate, so every +// pair must hold. A remote is only skipped on a claim that conflicts: gossip +// may carry part of what a peer attests, so silence on a pair is not failure, +// and the gate resolves it on attested facts. +func outsideLabels(required, claimed map[string]string, local bool) bool { + if len(required) == 0 { + return false } - return false + if local { + return !api.LabelsSatisfy(required, claimed) + } + return api.LabelsContradict(required, claimed) } // rankProviders applies hard constraints then orders the survivors: eligible @@ -74,41 +80,20 @@ func (f *openAIFacade) rankProviders(providers []modelProvider, requiredLabels m // still know the peer's claims. labels = f.peerLabels(p.peerID) } - // Labels are routing hints: a remote whose own claims mismatch the - // requirement is dropped early, but an unlabeled remote proceeds to - // the label gate, the authoritative fail-closed check on the - // provider's biscuit-attested labels (see labels_gate.go). Locals + // Labels are routing hints: a remote whose own claims contradict the + // caller's requirement is dropped early, but an unlabeled remote + // proceeds to the label gate, the authoritative fail-closed check on + // the provider's biscuit-attested labels (see labels_gate.go). Locals // have no gate, so their declared labels stay fail-closed here. - if len(requiredLabels) > 0 { - knownMismatch := len(labels) > 0 && !labelsAllowed(requiredLabels, labels) - if knownMismatch || (p.peerID == "" && !labelsAllowed(requiredLabels, labels)) { - recordFacadeRejection(reasonLabelMismatch) - continue - } + if outsideLabels(requiredLabels, labels, p.peerID == "") { + recordFacadeRejection(reasonLabelMismatch) + 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 - } + // The operator's egress floor, which the caller cannot waive; the same + // rule, accounted separately. + if outsideLabels(f.floor(), labels, p.peerID == "") { + recordFacadeRejection(reasonEgressFloorMismatch) + continue } if p.peerID == "" { locals = append(locals, p) diff --git a/sdk/README.md b/sdk/README.md index fba48d7d..f18855b1 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -459,26 +459,26 @@ holds against the control plane's records. multiaddr is dialed as given. - `/sam/mcp/1.0.0` client: `session.openMCP(peer, "mcp://")` sends the `AuthFrame` naming the service, verifies the provider's credential - and the caller's required labels (`checkPeerLabels`: several pairs are - met by any one of them, as `api.LabelCheck` joins them with `or`), then - runs the official MCP client over the varint-framed stream. JS: a - `Transport` for `@modelcontextprotocol/sdk`; Python: a pair of memory - streams pumped to and from the libp2p stream for `mcp.ClientSession`. - `""` as the target is the provider's own catalog (`list_local_services`, - `get_mesh_info`). + and the caller's required labels (`checkPeerLabels`: every pair must be + attested, as `api.LabelCheck` joins them with `,`), then runs the + official MCP client over the varint-framed stream. JS: a `Transport` for + `@modelcontextprotocol/sdk`; Python: a pair of memory streams pumped to + and from the libp2p stream for `mcp.ClientSession`. `""` as the target is + the provider's own catalog (`list_local_services`, `get_mesh_info`). - Egress floor: `join({ egressRequireLabels })` (`join(egress_require_labels=)`) - is `sam-node`'s `egress.require_labels` for an SDK member. The floor is - the conjunction (`api.LabelFloorCheck` joins the pairs with `,`): every - provider the session calls must attest all of them, on top of a call's - required labels, on every outbound call however the peer was named, MCP - and HTTP alike. Stated once at join and held for the session; a call - cannot waive or widen it. The three implementations agree on it, as they - do on the caller's requirement. The HTTP path (`request`, `fetch`, - `MeshTransport`) verifies the provider with or without a floor, through - the mutual `/sam/auth/1.0.0` handshake, as `sam-node`'s `VerifyPeerLabels` - does before its egress proxy sends anything; a positive verdict is kept - per peer for five minutes (`labelGateTTL`); a refusal is not kept. An - unmet floor is a `LabelsNotSatisfiedError` naming the floor. + is `sam-node`'s `egress.require_labels` for an SDK member: every provider + the session calls must attest all of them, on top of a call's required + labels, on every outbound call however the peer was named, MCP and HTTP + alike. It is the same rule as the caller's requirement (`api.LabelCheck`), + checked separately so no call can reach it. Stated once at join and held + for the session; a call cannot waive or widen it. The three + implementations agree on it, as they do on the caller's requirement. The + HTTP path (`request`, `fetch`, `MeshTransport`) verifies the provider with + or without a floor, through the mutual `/sam/auth/1.0.0` handshake, as + `sam-node`'s `VerifyPeerLabels` does before its egress proxy sends + anything; a positive verdict is kept per peer for five minutes + (`labelGateTTL`); a refusal is not kept. An unmet floor is a + `LabelsNotSatisfiedError` naming the floor. - `session.listTools(peer, service)` and `session.callTool(peer, service, tool, args)` on top of that. - Tests. Unit: each SDK calls a tool on an in-process provider that serves diff --git a/sdk/js/src/mcp.test.ts b/sdk/js/src/mcp.test.ts index b0fca7bd..5d70379f 100644 --- a/sdk/js/src/mcp.test.ts +++ b/sdk/js/src/mcp.test.ts @@ -169,24 +169,29 @@ test("required labels are checked on the provider's credential", async () => { assert.throws(() => requireLabels({ peerId: "p", expiration: new Date(), verifyingKey: cpKey, roles: [], labels: {} }, { team: "x" }), /team=x/); }); -test("a requirement of several labels is met by any one of them, as sam-node's checkPeerLabels", () => { +test("a requirement of several labels is met only by every one of them, as sam-node's checkPeerLabels", () => { // The cases of internal/node/labels_gate_test.go, run through the SDK's predicate. const attesting = (labels: Record) => ({ peerId: "p", expiration: new Date(), verifyingKey: cpKey, roles: [], labels }); // exact match requireLabels(attesting({ region: "us-east-1" }), { region: "us-east-1" }); - // any-of requirement matches one key - requireLabels(attesting({ region: "na-us", team: "platform" }), { region: "eu", team: "platform" }); + // every pair of two attested + requireLabels(attesting({ region: "na-us", team: "platform" }), { region: "na-us", team: "platform" }); + // one pair of two wrong fails, naming the whole requirement + assert.throws(() => requireLabels(attesting({ region: "na-us", team: "platform" }), { region: "eu", team: "platform" }), /does not attest every required label: region=eu, team=platform/); + // one pair of two missing fails + assert.throws(() => requireLabels(attesting({ region: "na-us" }), { region: "na-us", team: "platform" }), LabelsNotSatisfiedError); // no built-in hierarchy: coarser requirement fails a finer claim assert.throws(() => requireLabels(attesting({ region: "us-east-1" }), { region: "us" }), LabelsNotSatisfiedError); // disjoint labels fail assert.throws(() => requireLabels(attesting({ region: "na-us" }), { region: "eu" }), LabelsNotSatisfiedError); - // unattested token fails closed, naming every pair the caller asked for - assert.throws(() => requireLabels(attesting({}), { region: "eu", team: "platform" }), /region=eu, team=platform/); + // unattested token fails closed + assert.throws(() => requireLabels(attesting({}), { region: "eu", team: "platform" }), LabelsNotSatisfiedError); // an empty requirement is no requirement requireLabels(attesting({}), {}); + requireLabels(attesting({}), undefined); }); -test("the egress floor is met only by every one of its pairs, as sam-node's api.LabelFloorCheck", () => { +test("the egress floor is met only by every one of its pairs, as sam-node's api.LabelCheck", () => { const attesting = (labels: Record) => ({ peerId: "p", expiration: new Date(), verifyingKey: cpKey, roles: [], labels }); requireEgressLabels(attesting({ region: "eu", team: "platform" }), { region: "eu" }); requireEgressLabels(attesting({ region: "eu", team: "platform" }), { region: "eu", team: "platform" }); diff --git a/sdk/js/src/mcp.ts b/sdk/js/src/mcp.ts index fb92d6b1..46b86d54 100644 --- a/sdk/js/src/mcp.ts +++ b/sdk/js/src/mcp.ts @@ -88,7 +88,7 @@ export class StreamTransport implements Transport { } export interface MCPSessionOptions { - /** Labels the provider's credential must carry, e.g. { region: "eu" }. */ + /** Labels the provider's credential must all carry, e.g. { region: "eu", compliance: "gdpr" }. */ requiredLabels?: Record; /** The agent this call is made for; attribution beside the token, as in sam-node. */ agent?: string; @@ -104,54 +104,48 @@ export interface MCPSession { } /** - * The provider's credential lacks what the caller requires (any one pair) or - * what the session's egress floor requires (every pair), as checkPeerLabels refuses. + * The provider's credential lacks a label the caller requires or a label of + * the session's egress floor, as checkPeerLabels refuses. */ export class LabelsNotSatisfiedError extends Error { - constructor(peerId: string, required: string[], what = "carries none of the required labels") { + constructor(peerId: string, required: string[], what: string) { super(`peer ${peerId} ${what}: ${required.join(", ")}`); this.name = "LabelsNotSatisfiedError"; } } -/** - * A caller's requirement is satisfied by any one pair, as sam-node's - * api.LabelCheck (`check if label(k1, v1) or label(k2, v2)`): several pairs - * mean "any of these will do". The egress floor (requireEgressLabels) is the - * conjunction. - */ -export function requireLabels(provider: VerifiedBiscuit, required: Record | undefined): void { +function requireEveryPair(provider: VerifiedBiscuit, required: Record | undefined, what: string): void { if (!required) { return; } const pairs = Object.entries(required); - if (pairs.length === 0 || pairs.some(([k, v]) => provider.labels[k] === v)) { + if (pairs.every(([k, v]) => provider.labels[k] === v)) { return; } throw new LabelsNotSatisfiedError( provider.peerId, pairs.map(([k, v]) => `${k}=${v}`), + what, ); } /** - * The egress floor is met only by every one of its pairs, as sam-node's - * api.LabelFloorCheck (`check if label(k1, v1), label(k2, v2)`) for - * egress.require_labels: a floor takes no alternatives. Empty is no floor. + * A requirement is satisfied only when the provider attests every pair, as + * sam-node's api.LabelCheck (`check if label(k1, v1), label(k2, v2)`), the + * same rule as the egress floor. A map holds one value per key, so listing + * several pairs narrows the acceptable providers. Empty is no requirement. + */ +export function requireLabels(provider: VerifiedBiscuit, required: Record | undefined): void { + requireEveryPair(provider, required, "does not attest every required label"); +} + +/** + * The session's egress floor, sam-node's egress.require_labels: the same + * rule as requireLabels, refused with a message that names the floor. Empty + * is no floor. */ export function requireEgressLabels(provider: VerifiedBiscuit, required: Record | undefined): void { - if (!required) { - return; - } - const pairs = Object.entries(required); - if (pairs.every(([k, v]) => provider.labels[k] === v)) { - return; - } - throw new LabelsNotSatisfiedError( - provider.peerId, - pairs.map(([k, v]) => `${k}=${v}`), - "does not attest the egress floor", - ); + requireEveryPair(provider, required, "does not attest the egress floor"); } /** diff --git a/sdk/python/src/agent_mesh/mcp_client.py b/sdk/python/src/agent_mesh/mcp_client.py index 61e8574b..f9452780 100644 --- a/sdk/python/src/agent_mesh/mcp_client.py +++ b/sdk/python/src/agent_mesh/mcp_client.py @@ -47,33 +47,32 @@ class LabelsNotSatisfiedError(Exception): - """The provider's credential lacks what the caller requires (any one pair) - or what the session's egress floor requires (every pair), as - checkPeerLabels refuses.""" + """The provider's credential lacks a label the caller requires or a label + of the session's egress floor, as checkPeerLabels refuses.""" - def __init__(self, peer_id: str, required: Sequence[str], what: str = "carries none of the required labels"): + def __init__(self, peer_id: str, required: Sequence[str], what: str): super().__init__(f"peer {peer_id} {what}: {', '.join(required)}") -def require_labels(provider: VerifiedBiscuit, required: Optional[Mapping[str, str]]) -> None: - """A caller's requirement is satisfied by any one pair, as sam-node's - api.LabelCheck (`check if label(k1, v1) or label(k2, v2)`): several pairs - mean "any of these will do". The egress floor (require_egress_labels) is the - conjunction.""" - if not required: - return - if any(provider.labels.get(k) == v for k, v in required.items()): +def _require_every_pair(provider: VerifiedBiscuit, required: Optional[Mapping[str, str]], what: str) -> None: + if not required or all(provider.labels.get(k) == v for k, v in required.items()): return - raise LabelsNotSatisfiedError(provider.peer_id, [f"{k}={v}" for k, v in required.items()]) + raise LabelsNotSatisfiedError(provider.peer_id, [f"{k}={v}" for k, v in required.items()], what) + + +def require_labels(provider: VerifiedBiscuit, required: Optional[Mapping[str, str]]) -> None: + """A requirement is satisfied only when the provider attests every pair, as + sam-node's api.LabelCheck (`check if label(k1, v1), label(k2, v2)`), the + same rule as the egress floor. A map holds one value per key, so listing + several pairs narrows the acceptable providers. Empty is no requirement.""" + _require_every_pair(provider, required, "does not attest every required label") def require_egress_labels(provider: VerifiedBiscuit, required: Optional[Mapping[str, str]]) -> None: - """The egress floor is met only by every one of its pairs, as sam-node's - api.LabelFloorCheck (`check if label(k1, v1), label(k2, v2)`) for - egress.require_labels: a floor takes no alternatives. Empty is no floor.""" - if not required or all(provider.labels.get(k) == v for k, v in required.items()): - return - raise LabelsNotSatisfiedError(provider.peer_id, [f"{k}={v}" for k, v in required.items()], "does not attest the egress floor") + """The session's egress floor, sam-node's egress.require_labels: the same + rule as require_labels, refused with a message that names the floor. Empty + is no floor.""" + _require_every_pair(provider, required, "does not attest the egress floor") @dataclass diff --git a/sdk/python/tests/test_mcp.py b/sdk/python/tests/test_mcp.py index d5f4002f..936a3976 100644 --- a/sdk/python/tests/test_mcp.py +++ b/sdk/python/tests/test_mcp.py @@ -193,9 +193,10 @@ async def main(): with pytest.raises(LabelsNotSatisfiedError): async with open_mcp_session(caller, pid, frame(caller_biscuit, "mcp://calc"), [CP_KEY], required_labels={"region": "us"}): pass - # Several pairs are met by any one of them; the provider attests region=eu only. - async with open_mcp_session(caller, pid, frame(caller_biscuit, "mcp://calc"), [CP_KEY], required_labels={"region": "eu", "team": "platform"}): - pass + # Several pairs must all be attested; the provider attests region=eu only. + with pytest.raises(LabelsNotSatisfiedError): + async with open_mcp_session(caller, pid, frame(caller_biscuit, "mcp://calc"), [CP_KEY], required_labels={"region": "eu", "team": "platform"}): + pass # The egress floor is met only by every one of its pairs, beside the caller's requirement. async with open_mcp_session(caller, pid, frame(caller_biscuit, "mcp://calc"), [CP_KEY], egress_require_labels={"region": "eu"}): @@ -243,9 +244,9 @@ async def with_timeout(): trio.run(with_timeout) -def test_a_requirement_of_several_labels_is_met_by_any_one_of_them(): +def test_a_requirement_of_several_labels_is_met_only_by_every_one_of_them(): """The cases of internal/node/labels_gate_test.go, run through the SDK's - predicate: a caller naming several pairs means any of these will do, as + predicate: a caller naming several pairs requires all of them, as sam-node's checkPeerLabels and api.LabelCheck read it.""" from datetime import datetime, timezone @@ -254,16 +255,22 @@ def attesting(labels: dict) -> VerifiedBiscuit: # exact match require_labels(attesting({"region": "us-east-1"}), {"region": "us-east-1"}) - # any-of requirement matches one key - require_labels(attesting({"region": "na-us", "team": "platform"}), {"region": "eu", "team": "platform"}) + # every pair of two attested + require_labels(attesting({"region": "na-us", "team": "platform"}), {"region": "na-us", "team": "platform"}) + # one pair of two wrong fails, naming the whole requirement + with pytest.raises(LabelsNotSatisfiedError, match="does not attest every required label: region=eu, team=platform"): + require_labels(attesting({"region": "na-us", "team": "platform"}), {"region": "eu", "team": "platform"}) + # one pair of two missing fails + with pytest.raises(LabelsNotSatisfiedError): + require_labels(attesting({"region": "na-us"}), {"region": "na-us", "team": "platform"}) # no built-in hierarchy: coarser requirement fails a finer claim with pytest.raises(LabelsNotSatisfiedError): require_labels(attesting({"region": "us-east-1"}), {"region": "us"}) # disjoint labels fail with pytest.raises(LabelsNotSatisfiedError): require_labels(attesting({"region": "na-us"}), {"region": "eu"}) - # unattested token fails closed, naming every pair the caller asked for - with pytest.raises(LabelsNotSatisfiedError, match="region=eu, team=platform"): + # unattested token fails closed + with pytest.raises(LabelsNotSatisfiedError): require_labels(attesting({}), {"region": "eu", "team": "platform"}) # an empty requirement is no requirement require_labels(attesting({}), {}) @@ -271,8 +278,8 @@ def attesting(labels: dict) -> VerifiedBiscuit: def test_the_egress_floor_is_met_only_by_every_one_of_its_pairs(): - """sam-node's api.LabelFloorCheck for egress.require_labels, run through - the SDK's predicate: a floor takes no alternatives.""" + """sam-node's api.LabelCheck for egress.require_labels, run through the + SDK's predicate: a floor takes no alternatives.""" from datetime import datetime, timezone def attesting(labels: dict) -> VerifiedBiscuit: diff --git a/site/content/docs/concepts/authorization.md b/site/content/docs/concepts/authorization.md index 15a73683..6e29731c 100644 --- a/site/content/docs/concepts/authorization.md +++ b/site/content/docs/concepts/authorization.md @@ -216,19 +216,20 @@ Labels are used in three places: on the node's `/v1` inference endpoints or on a proxied A2A request, or passes `required_labels` to `call_remote_tool`. Before it sends any request data, the calling node fetches the provider's credential through the - mutual handshake, verifies it, and confirms that the label facts are - present. A provider that cannot show them is skipped. + mutual handshake, verifies it, and confirms that every label named is + attested in it. A provider that cannot show them all is skipped. - **An operator drawing a boundary** sets `egress.require_labels` in the node configuration. Every provider this node talks to must attest all of those labels, whether or not the caller asked for any. The caller can add further requirements but cannot remove the operator's. -The header and the operator floor have different matching rules, and the -difference follows from their purpose. The header is any-of: the caller is -choosing among acceptable providers. The floor is all-of: the operator is -drawing a line. Labels seen in discovery results are only used to rank -candidates. The only labels that authorize anything are the signed ones in a -credential. +The header and the operator floor follow the same matching rule: a map of +`key=value` pairs, one value per key, and the provider must attest every +pair. Listing more pairs narrows the set of acceptable providers, as it does +in a Kubernetes label selector or a Prometheus matcher. A list of pairs does +not express alternatives for one key. Labels seen in discovery results are +only used to rank candidates. The only labels that authorize anything are +the signed ones in a credential. ## Agents acting through a node diff --git a/site/content/docs/concepts/boundaries.md b/site/content/docs/concepts/boundaries.md index 75a66cf3..da463960 100644 --- a/site/content/docs/concepts/boundaries.md +++ b/site/content/docs/concepts/boundaries.md @@ -62,7 +62,7 @@ a boundary: - **A caller refuses providers outside the boundary.** With `X-Sam-Required-Labels` on a request, the calling node verifies the provider's credential before it sends anything. A provider that cannot - show the label is skipped. + show every label named is skipped. - **An operator sets a floor for a whole node.** `egress.require_labels` in the node configuration applies to every outbound request from that node. Callers cannot lower it. diff --git a/site/content/docs/concepts/networking.md b/site/content/docs/concepts/networking.md index dda1d662..d6f28237 100644 --- a/site/content/docs/concepts/networking.md +++ b/site/content/docs/concepts/networking.md @@ -116,9 +116,9 @@ path. A caller can restrict which providers are acceptable with `X-Sam-Required-Labels: key=value[,key=value]`. The node then verifies the -provider's credential and confirms that the labels are attested in it before -forwarding. The header is removed before the request leaves the node. An -operator can set a floor that every provider must meet with +provider's credential and confirms that every listed label is attested in it +before forwarding. The header is removed before the request leaves the node. +An operator can set a floor that every provider must meet with `egress.require_labels` in the node configuration. ## Mesh events diff --git a/site/content/docs/guides/exposing-services.md b/site/content/docs/guides/exposing-services.md index 13a83755..9e89fe84 100644 --- a/site/content/docs/guides/exposing-services.md +++ b/site/content/docs/guides/exposing-services.md @@ -196,8 +196,9 @@ labels: Labels are attested at enrollment, so the role the node enrolls with must permit them (`allowed_labels: ["region=*", "team=platform"]`, or `["*"]`). Enrollment is refused if a label is not allowed by the role. Once attested, -callers can require the labels (`X-Sam-Required-Labels: region=eu-west-1`), -and the node can require labels from its callers: +callers can require the labels (`X-Sam-Required-Labels: region=eu-west-1`; +several pairs must all be attested), and the node can require labels from +its callers: ```yaml attenuation: diff --git a/site/content/docs/guides/native-sdks.md b/site/content/docs/guides/native-sdks.md index 12b310b6..a6ef47c6 100644 --- a/site/content/docs/guides/native-sdks.md +++ b/site/content/docs/guides/native-sdks.md @@ -884,14 +884,14 @@ through every router that admitted the caller, and the router opens a circuit because it admitted the agent too. Either way the SDK verifies the peer's credential before sending anything, and `requiredLabels` (`required_labels` in Python) refuses a peer whose control-plane-attested -labels carry none of the pairs you ask for; one matching pair is enough, -as with `X-Sam-Required-Labels` on a `sam-node`. A floor is the other way -round: `join({ egressRequireLabels })` (`join(egress_require_labels=)`) -names labels every peer the session calls must attest, all of them, as -`egress.require_labels` does for a `sam-node`. It is stated once at `join` -and held for the session, on every call and however the peer was named; -the agent's calls cannot waive or widen it. The floor belongs to the -program that calls `join`; no configuration outside the process sets it. +labels do not carry every pair you ask for, as `X-Sam-Required-Labels` does +on a `sam-node`. A floor uses the same rule for the whole session: +`join({ egressRequireLabels })` (`join(egress_require_labels=)`) names +labels every peer the session calls must attest, as `egress.require_labels` +does for a `sam-node`. It is stated once at `join` and held for the +session, on every call and however the peer was named; the agent's calls +cannot waive or widen it. The floor belongs to the program that calls +`join`; no configuration outside the process sets it. `acceptA2A` (`accept_a2a`) fetched the mesh policy and started answering. Every caller must present a credential signed by a trusted control plane diff --git a/site/content/docs/reference/node-api.md b/site/content/docs/reference/node-api.md index 09f54102..a937a09e 100644 --- a/site/content/docs/reference/node-api.md +++ b/site/content/docs/reference/node-api.md @@ -108,7 +108,7 @@ Returns `description`, `input_schema` and `output_schema`. | `peer_id` | Required. | | `tool_name` | Required, namespaced. | | `arguments` | Object matching the tool's `input_schema`. | -| `required_labels` | `key=value[,key=value]`. The call is refused unless the peer's credential attests at least one of them. | +| `required_labels` | `key=value[,key=value]`. The call is refused unless the peer's credential attests every one of them. | Returns the tool's result content. A policy denial comes back as a tool error that the caller can read, not as a transport failure. @@ -146,7 +146,7 @@ Two headers modify a proxied request: | Header | Effect | |---|---| -| `X-Sam-Required-Labels: k=v[,k=v]` | Verify that the peer attests at least one of the pairs before forwarding. Otherwise `403`. Removed before forwarding. Also honoured on `/v1/*`. | +| `X-Sam-Required-Labels: k=v[,k=v]` | Forward only to a peer whose credential attests every listed pair. Otherwise `403`. Removed before forwarding. Also honoured on `/v1/*`. | | `X-Sam-Agent: ` | Name the agent for which this request is made. Only meaningful when sent by a `sam-box`. See the [preview](../../preview/sandboxed-agents/). | ### Talking MCP through the proxy diff --git a/site/content/docs/reference/node-config.md b/site/content/docs/reference/node-config.md index d818d2c6..bb194f93 100644 --- a/site/content/docs/reference/node-config.md +++ b/site/content/docs/reference/node-config.md @@ -146,10 +146,11 @@ syntax and the same errors. | `require_labels` | A map of `key: value`. Every remote provider that this node sends a request to must have all of these labels attested in its credential, whether or not the caller asked for labels. Same syntax rules as `labels`. | This is the operator's floor. A caller's `X-Sam-Required-Labels` header is -checked separately. It can add requirements but cannot remove or relax the -floor. A floor that names a label which no provider carries makes the node -unable to reach any provider, which can serve as an egress kill switch. Omit -the block for no floor. +checked separately, with the same rule: every pair it names must be attested. +It can add requirements but cannot remove or relax the floor. A floor that +names a label which no provider carries makes the node unable to reach any +provider, which can serve as an egress kill switch. Omit the block for no +floor. ## Kubernetes diff --git a/tests/integration/sdk_mesh_test.go b/tests/integration/sdk_mesh_test.go index 61508bf1..364ff88b 100644 --- a/tests/integration/sdk_mesh_test.go +++ b/tests/integration/sdk_mesh_test.go @@ -83,9 +83,9 @@ type sdkMesh struct { } // sdkMeshLabels is what every provider in the mesh attests, the node and the -// SDK members alike: the inputs of the "any-of requirement matches one key" -// case of internal/node/labels_gate_test.go, so the caller-side check can be -// run against a real credential from every implementation. +// SDK members alike: the inputs of the "one pair of two wrong fails" case of +// internal/node/labels_gate_test.go, so the caller-side check can be run +// against a real credential from every implementation. var sdkMeshLabels = map[string]string{"region": "na-us", "team": "platform"} // The egress destination the sam-node serves, assigned to it by label, and @@ -96,17 +96,17 @@ const ( ) // sdkMeshLabelRequirements are the caller requirements the matrix runs against -// sdkMeshLabels: one pair of two matches, so a requirement is met by any of its -// pairs (api.LabelCheck joins them with `or`); none of the pairs matches, so -// it is refused; one pair that matches; the coarser value of a finer claim, so -// there is no hierarchy. +// sdkMeshLabels: both pairs attested, so a requirement of several pairs is +// met; one pair of two wrong, so it is refused (api.LabelCheck joins them with +// `,`); one pair that matches; the coarser value of a finer claim, so there is +// no hierarchy. var sdkMeshLabelRequirements = []struct { name string required map[string]string allowed bool }{ - {"any-of requirement matches one key", map[string]string{"region": "eu", "team": "platform"}, true}, - {"disjoint labels fail", map[string]string{"region": "eu", "team": "sre"}, false}, + {"every pair attested", map[string]string{"region": "na-us", "team": "platform"}, true}, + {"one pair of two wrong fails", map[string]string{"region": "eu", "team": "platform"}, false}, {"exact match", map[string]string{"team": "platform"}, true}, {"no built-in hierarchy", map[string]string{"region": "na"}, false}, } @@ -468,12 +468,12 @@ func TestNativeSDKsMesh(t *testing.T) { } // The caller-side label requirement means the same thing in every - // implementation: a requirement of several pairs is met by any one of - // them, none of them is a refusal, and a value is matched whole. The node - // and every SDK member attest the same labels, so the matrix below runs - // each requirement from each caller against a credential each provider - // minted: SDK -> node over /sam/mcp/1.0.0, and node -> SDK through the - // egress proxy's X-Sam-Required-Labels, which runs checkPeerLabels. + // implementation: a requirement of several pairs is met only when every + // one of them is attested, and a value is matched whole. The node and + // every SDK member attest the same labels, so the matrix below runs each + // requirement from each caller against a credential each provider minted: + // SDK -> node over /sam/mcp/1.0.0, and node -> SDK through the egress + // proxy's X-Sam-Required-Labels, which runs checkPeerLabels. t.Run("required-labels", func(t *testing.T) { for _, m := range members { if got := m.auth(t, samNode.p2pAddr).Labels; got["region"] != sdkMeshLabels["region"] || got["team"] != sdkMeshLabels["team"] {