ai_memory/federation/peer_attestation.rs
1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! v0.7.0 federation security — peer attestation + scope-allowlist
5//! substrate for `/api/v1/sync/push` and `/api/v1/sync/since`.
6//!
7//! ## Gap context (red-team #230, issues #238 + #239)
8//!
9//! - **#238** — `SyncPushBody::sender_agent_id` is a body-claimed
10//! identity. Pre-v0.7.0 the receiver logged it for audit and used
11//! it to charge per-agent quotas, but never attested it against
12//! anything. A peer with a valid mTLS cert could claim ANY
13//! `agent_id` in the body, defeating per-agent audit-trail
14//! integrity.
15//! - **#239** — `/api/v1/sync/since` returned every memory newer
16//! than the watermark with no per-peer namespace scope. Compromise
17//! of one mTLS peer key exfiltrated the entire database.
18//!
19//! ## Substrate honesty (operator-must-read)
20//!
21//! The cryptographic anchor for "this connection is from an authorised
22//! peer" today is the mTLS client-cert fingerprint pin
23//! (`src/tls.rs::FingerprintAllowlistVerifier`). axum-server 0.8 does
24//! **not** propagate the verified peer certificate (or its SAN/CN) to
25//! axum handlers — there is no per-request extension that exposes the
26//! rustls server connection. Closing that gap requires either a
27//! non-trivial axum-server PR or a new x509-parser dependency wired
28//! into a custom `ClientCertVerifier` that stashes per-connection
29//! state. **That work is escalated to v0.8.0** and tracked under the
30//! follow-up to issues #238/#239 in the PR body that landed this
31//! module.
32//!
33//! What this module DOES give v0.7.0:
34//!
35//! 1. A NEW required outbound header `x-peer-id` carrying the peer's
36//! self-claim of its `sender_agent_id`. The federation client
37//! (`src/federation/sync.rs::post_once`) attaches it on every
38//! outbound `/sync/push` and `/sync/since` request. The receiver
39//! cross-checks `body.sender_agent_id` against this header — the
40//! body field can no longer silently disagree with the wire-level
41//! peer-id without an explicit operator override.
42//! 2. An operator-configured allowlist that binds **claimed peer-id**
43//! to **allowed sender_agent_ids** + **allowed namespaces**.
44//! Loaded from the env var `AI_MEMORY_FED_PEER_ATTESTATION` (JSON;
45//! see [`PeerAttestationConfig::from_env`] for the schema). Peers
46//! not in the allowlist still get a clear refusal envelope.
47//! 3. Opt-in env bypasses so the live Mac Mini test cell and the
48//! DigitalOcean campaign keep working without config updates
49//! (`AI_MEMORY_FED_TRUST_BODY_AGENT_ID=1`,
50//! `AI_MEMORY_FED_SYNC_TRUST_PEER=1`).
51//!
52//! The end-to-end trust chain in v0.7.0 is therefore:
53//!
54//! ```text
55//! Operator configures mTLS allowlist (fingerprints)
56//! └─ rustls verifies peer client cert at handshake
57//! └─ HTTP request reaches handler ONLY if cert was pinned
58//! └─ handler reads `x-peer-id` header (operator-bound to
59//! fingerprints via deployment runbook, NOT cryptographic-
60//! ally tied to the cert TODAY)
61//! └─ this module validates body.sender_agent_id /
62//! filters /sync/since projection.
63//! ```
64//!
65//! The weak link is the operator-bound binding between fingerprint
66//! and `x-peer-id`. v0.8.0 will replace that with the cert-SAN
67//! attestation surface and remove this caveat.
68
69use serde::{Deserialize, Serialize};
70use std::collections::HashMap;
71
72/// Env var carrying the operator's per-peer attestation allowlist
73/// (JSON). Absent / parse-error = empty allowlist (default-deny on
74/// `/sync/since` unless [`SYNC_TRUST_PEER_ENV`] is set).
75pub const PEER_ATTESTATION_ENV: &str = "AI_MEMORY_FED_PEER_ATTESTATION";
76
77/// Env var that, when set to `"1"`, disables the #238 attestation
78/// check and reverts `/sync/push` to its pre-v0.7.0 posture (accept
79/// any body-claimed `sender_agent_id`). Backwards-compat for test
80/// cells where the operator hasn't yet wired the allowlist.
81pub const TRUST_BODY_AGENT_ID_ENV: &str = "AI_MEMORY_FED_TRUST_BODY_AGENT_ID";
82
83/// Env var that, when set to `"1"`, disables the #239 namespace-
84/// allowlist check and reverts `/sync/since` to its pre-v0.7.0
85/// "full dump" posture. Backwards-compat for the v0.6.x federation
86/// mesh and the live test cells that don't yet ship a peer-scope
87/// allowlist.
88pub const SYNC_TRUST_PEER_ENV: &str = "AI_MEMORY_FED_SYNC_TRUST_PEER";
89
90/// HTTP header carrying the peer's self-claim of `sender_agent_id`.
91/// Lowercase per the HTTP/2 wire convention; axum's `HeaderMap`
92/// lookups are case-insensitive.
93pub const PEER_ID_HEADER: &str = "x-peer-id";
94
95/// Allowlist row for a single peer (keyed by claimed peer-id).
96///
97/// The `allowed_sender_agent_ids` field, when empty, is interpreted
98/// as "peer may push memories where `body.sender_agent_id` equals
99/// the peer-id itself" — the minimal-trust default for a peer that
100/// only authors as itself. When non-empty, it overrides that default
101/// and the list (exact strings, no glob) is the authoritative set of
102/// `body.sender_agent_id` values the peer may claim.
103///
104/// `allowed_namespaces` follows the glob convention used elsewhere
105/// in the codebase: `*` matches a single segment, `**` matches any
106/// suffix. Empty = peer may not pull any namespace (default-deny).
107#[derive(Clone, Debug, Default, Serialize, Deserialize)]
108pub struct PeerScope {
109 /// Exact `body.sender_agent_id` values this peer may claim on
110 /// `/sync/push`. Empty = only the peer-id itself.
111 #[serde(default)]
112 pub allowed_sender_agent_ids: Vec<String>,
113 /// Glob patterns matched against `Memory::namespace` on
114 /// `/sync/since`. Empty = peer may not pull any rows
115 /// (default-deny) unless [`SYNC_TRUST_PEER_ENV`] is set.
116 #[serde(default)]
117 pub allowed_namespaces: Vec<String>,
118}
119
120/// Operator-configured federation peer-attestation map. Loaded from
121/// the [`PEER_ATTESTATION_ENV`] env var as JSON:
122///
123/// ```json
124/// {
125/// "peer-node-1": {
126/// "allowed_sender_agent_ids": ["ai:peer-node-1@host", "alice"],
127/// "allowed_namespaces": ["public/*", "shared/team-x/**"]
128/// },
129/// "peer-node-2": {
130/// "allowed_namespaces": ["public/*"]
131/// }
132/// }
133/// ```
134///
135/// The empty map (`{}` or no env var at all) is a valid state. It
136/// triggers the default-deny posture on `/sync/since` and the
137/// "header must equal body" posture on `/sync/push`.
138#[derive(Clone, Debug, Default)]
139pub struct PeerAttestationConfig {
140 pub peers: HashMap<String, PeerScope>,
141}
142
143/// Reason a body-claimed `sender_agent_id` failed attestation against
144/// the wire-level `x-peer-id` header.
145#[derive(Debug, Clone)]
146pub enum AttestError {
147 /// `x-peer-id` header absent AND env bypass NOT set. Caller
148 /// should return 403.
149 HeaderMissing,
150 /// `x-peer-id` header present, body field present, no allowlist
151 /// row exists for this peer-id, AND `body.sender_agent_id` does
152 /// not equal the header. The peer is claiming an identity it has
153 /// no operator-configured permission to claim.
154 Mismatch {
155 claimed: String,
156 peer_header: String,
157 },
158}
159
160impl AttestError {
161 /// Stable machine-readable tag for the error envelope.
162 #[must_use]
163 pub fn tag(&self) -> &'static str {
164 match self {
165 Self::HeaderMissing => "peer_id_header_missing",
166 Self::Mismatch { .. } => "sender_agent_id_mismatch",
167 }
168 }
169}
170
171impl PeerAttestationConfig {
172 /// Load the allowlist from the [`PEER_ATTESTATION_ENV`] env var.
173 /// Missing env var = empty config (default-deny). Parse error =
174 /// empty config + a `tracing::warn!` so the operator sees the
175 /// typo immediately. Refusing to start on a malformed allowlist
176 /// would be a self-DOS hazard during config rollouts.
177 #[must_use]
178 pub fn from_env() -> Self {
179 match std::env::var(PEER_ATTESTATION_ENV) {
180 Ok(s) if !s.trim().is_empty() => {
181 match serde_json::from_str::<HashMap<String, PeerScope>>(&s) {
182 Ok(peers) => Self { peers },
183 Err(e) => {
184 tracing::warn!(
185 target: "federation::peer_attestation",
186 env = PEER_ATTESTATION_ENV,
187 error = %e,
188 "failed to parse peer-attestation env var as JSON — \
189 falling back to empty allowlist (default-deny on \
190 /sync/since, header-must-equal-body on /sync/push)"
191 );
192 Self::default()
193 }
194 }
195 }
196 _ => Self::default(),
197 }
198 }
199
200 /// Lookup scope for a claimed peer-id. Returns `None` when the
201 /// operator has not configured any row for this peer.
202 #[must_use]
203 pub fn scope_for(&self, peer_id: &str) -> Option<&PeerScope> {
204 self.peers.get(peer_id)
205 }
206
207 /// v0.7.0 #1056 — whether the operator has enrolled at least
208 /// one peer in the allowlist. Used by the federation handlers
209 /// to distinguish the zero-config posture (no allowlist set =
210 /// trust signed peer-ids on faith) from the configured posture
211 /// (allowlist set = refuse any peer-id not in the map).
212 #[must_use]
213 pub fn has_allowlist(&self) -> bool {
214 !self.peers.is_empty()
215 }
216}
217
218/// Whether the operator has explicitly opted out of #238 attestation
219/// (legacy behaviour: trust the body field).
220#[must_use]
221pub fn trust_body_agent_id_bypass() -> bool {
222 matches!(std::env::var(TRUST_BODY_AGENT_ID_ENV).as_deref(), Ok("1"))
223}
224
225/// Whether the operator has explicitly opted out of #239 scope
226/// filtering (legacy behaviour: full database dump per peer).
227#[must_use]
228pub fn sync_trust_peer_bypass() -> bool {
229 matches!(std::env::var(SYNC_TRUST_PEER_ENV).as_deref(), Ok("1"))
230}
231
232/// #238 attestation core.
233///
234/// Validates that the body-claimed `sender_agent_id` is one this
235/// peer (identified by the `x-peer-id` header) is operator-permitted
236/// to claim.
237///
238/// Decision matrix:
239///
240/// | `peer_header` | `body_sender` | allowlist row | result |
241/// |---------------|-----------------------|---------------|-------------------|
242/// | `None` | any | n/a | [`AttestError::HeaderMissing`] |
243/// | `Some(p)` | `None` or empty | n/a | Ok (legacy unauthored push) |
244/// | `Some(p)` | `Some(s)` where `s == p` | n/a | Ok (peer authoring as itself) |
245/// | `Some(p)` | `Some(s)` where `s != p` | None | [`AttestError::Mismatch`] |
246/// | `Some(p)` | `Some(s)` where `s != p` | Some(scope), `s ∈ scope.allowed_sender_agent_ids` | Ok |
247/// | `Some(p)` | `Some(s)` where `s != p` | Some(scope), `s ∉ scope.allowed_sender_agent_ids` | [`AttestError::Mismatch`] |
248///
249/// `body_sender == Some("")` is treated as `None` to match the wire
250/// reality (federation clients pre-v0.7.0 sometimes serialise the
251/// field as the empty string instead of omitting it).
252///
253/// # Errors
254///
255/// Returns [`AttestError`] when the attestation contract is violated;
256/// callers should render 403 with a structured error envelope.
257pub fn attest_sender(
258 peer_header: Option<&str>,
259 body_sender: Option<&str>,
260 config: &PeerAttestationConfig,
261) -> Result<(), AttestError> {
262 let peer = match peer_header.map(str::trim).filter(|s| !s.is_empty()) {
263 Some(p) => p,
264 None => return Err(AttestError::HeaderMissing),
265 };
266 let claimed = match body_sender.map(str::trim).filter(|s| !s.is_empty()) {
267 Some(c) => c,
268 // Legacy push with no body claim — peer is implicitly authoring as itself.
269 None => return Ok(()),
270 };
271 if claimed == peer {
272 return Ok(());
273 }
274 if let Some(scope) = config.scope_for(peer)
275 && scope
276 .allowed_sender_agent_ids
277 .iter()
278 .any(|a| a.as_str() == claimed)
279 {
280 return Ok(());
281 }
282 Err(AttestError::Mismatch {
283 claimed: claimed.to_string(),
284 peer_header: peer.to_string(),
285 })
286}
287
288/// Glob match used by [`namespace_allowed`] — supports `*` (single
289/// segment) and `**` (any suffix). Mirrors the convention used
290/// elsewhere in the codebase (governance rules, allowlist patterns).
291/// Pure-function ASCII glob; no regex engine to avoid a new dep.
292///
293/// Re-exported as [`namespace_allowed_test_glob`] for callers that
294/// need to drive the per-pattern decision directly (the `sync_since`
295/// handler iterates the scope's pattern list itself so the
296/// `excluded_for_scope` count stays accurate against the pre-filter
297/// projection).
298#[must_use]
299pub fn namespace_allowed_test_glob(pattern: &str, target: &str) -> bool {
300 glob_match(pattern, target)
301}
302
303#[must_use]
304fn glob_match(pattern: &str, target: &str) -> bool {
305 if pattern == "**" || pattern == "*" {
306 return true;
307 }
308 if let Some(prefix) = pattern.strip_suffix("/**") {
309 // `prefix/**` matches `prefix` itself OR anything starting with `prefix/`.
310 return target == prefix || target.starts_with(&format!("{prefix}/"));
311 }
312 if let Some(prefix) = pattern.strip_suffix("/*") {
313 // `prefix/*` matches exactly one path-segment after `prefix/`.
314 if let Some(rest) = target.strip_prefix(&format!("{prefix}/")) {
315 return !rest.contains('/');
316 }
317 return false;
318 }
319 if let Some(suffix) = pattern.strip_prefix("*/") {
320 // `*/suffix` matches exactly one path-segment before `/suffix`.
321 if let Some(rest) = target.strip_suffix(&format!("/{suffix}")) {
322 return !rest.contains('/');
323 }
324 return false;
325 }
326 pattern == target
327}
328
329/// #239 scope-filter core.
330///
331/// Returns `true` when `namespace` is allowed for the peer identified
332/// by `peer_header`. Decision matrix:
333///
334/// | `peer_header` | scope row | bypass env | result |
335/// |---------------|--------------|------------|--------|
336/// | `None` | n/a | unset | false (default-deny) |
337/// | `None` | n/a | set | true (legacy full dump) |
338/// | `Some(p)` | None | unset | false (default-deny) |
339/// | `Some(p)` | None | set | true (legacy full dump) |
340/// | `Some(p)` | Some(scope) | unset/set | true iff any pattern in `scope.allowed_namespaces` matches `namespace` |
341///
342/// The bypass env (`AI_MEMORY_FED_SYNC_TRUST_PEER=1`) ONLY widens
343/// the "no scope row" case; once a scope row exists for the peer,
344/// its namespace list is the authoritative gate and the bypass is
345/// ignored (operator's explicit allowlist wins over the legacy
346/// override).
347#[must_use]
348pub fn namespace_allowed(
349 peer_header: Option<&str>,
350 namespace: &str,
351 config: &PeerAttestationConfig,
352) -> bool {
353 let Some(peer) = peer_header.map(str::trim).filter(|s| !s.is_empty()) else {
354 return sync_trust_peer_bypass();
355 };
356 match config.scope_for(peer) {
357 Some(scope) => scope
358 .allowed_namespaces
359 .iter()
360 .any(|p| glob_match(p, namespace)),
361 None => sync_trust_peer_bypass(),
362 }
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368
369 fn cfg(rows: &[(&str, PeerScope)]) -> PeerAttestationConfig {
370 let peers = rows
371 .iter()
372 .map(|(k, v)| ((*k).to_string(), v.clone()))
373 .collect();
374 PeerAttestationConfig { peers }
375 }
376
377 // ---- attest_sender ---------------------------------------------------
378
379 #[test]
380 fn attest_header_missing_errors() {
381 let cfg = PeerAttestationConfig::default();
382 let err = attest_sender(None, Some("alice"), &cfg).unwrap_err();
383 assert!(matches!(err, AttestError::HeaderMissing));
384 assert_eq!(err.tag(), "peer_id_header_missing");
385 }
386
387 #[test]
388 fn attest_header_empty_treated_as_missing() {
389 let cfg = PeerAttestationConfig::default();
390 let err = attest_sender(Some(" "), Some("alice"), &cfg).unwrap_err();
391 assert!(matches!(err, AttestError::HeaderMissing));
392 }
393
394 #[test]
395 fn attest_body_missing_passes_legacy_unauthored() {
396 // No body-claimed sender + peer header present = legacy pre-v0.7.0
397 // peer that didn't author rows. Accept.
398 let cfg = PeerAttestationConfig::default();
399 attest_sender(Some("peer-1"), None, &cfg).unwrap();
400 attest_sender(Some("peer-1"), Some(""), &cfg).unwrap();
401 }
402
403 #[test]
404 fn attest_self_authoring_passes() {
405 let cfg = PeerAttestationConfig::default();
406 attest_sender(Some("peer-1"), Some("peer-1"), &cfg).unwrap();
407 }
408
409 #[test]
410 fn attest_mismatch_no_allowlist_errors() {
411 let cfg = PeerAttestationConfig::default();
412 let err = attest_sender(Some("peer-1"), Some("alice"), &cfg).unwrap_err();
413 match err {
414 AttestError::Mismatch {
415 claimed,
416 peer_header,
417 } => {
418 assert_eq!(claimed, "alice");
419 assert_eq!(peer_header, "peer-1");
420 }
421 other => panic!("expected Mismatch, got: {other:?}"),
422 }
423 }
424
425 #[test]
426 fn attest_mismatch_with_matching_allowlist_passes() {
427 let cfg = cfg(&[(
428 "peer-1",
429 PeerScope {
430 allowed_sender_agent_ids: vec!["alice".to_string(), "bob".to_string()],
431 ..PeerScope::default()
432 },
433 )]);
434 attest_sender(Some("peer-1"), Some("alice"), &cfg).unwrap();
435 attest_sender(Some("peer-1"), Some("bob"), &cfg).unwrap();
436 }
437
438 #[test]
439 fn attest_mismatch_outside_allowlist_errors() {
440 let cfg = cfg(&[(
441 "peer-1",
442 PeerScope {
443 allowed_sender_agent_ids: vec!["alice".to_string()],
444 ..PeerScope::default()
445 },
446 )]);
447 let err = attest_sender(Some("peer-1"), Some("eve"), &cfg).unwrap_err();
448 assert!(matches!(err, AttestError::Mismatch { .. }));
449 }
450
451 // ---- glob_match -----------------------------------------------------
452
453 #[test]
454 fn glob_wildcard_all() {
455 assert!(glob_match("*", "anything"));
456 assert!(glob_match("**", "anything/even/nested"));
457 }
458
459 #[test]
460 fn glob_prefix_double_star() {
461 assert!(glob_match("public/**", "public"));
462 assert!(glob_match("public/**", "public/a"));
463 assert!(glob_match("public/**", "public/a/b/c"));
464 assert!(!glob_match("public/**", "private"));
465 assert!(!glob_match("public/**", "publicx"));
466 }
467
468 #[test]
469 fn glob_prefix_single_star() {
470 assert!(glob_match("public/*", "public/foo"));
471 assert!(!glob_match("public/*", "public/foo/bar"));
472 assert!(!glob_match("public/*", "public"));
473 }
474
475 #[test]
476 fn glob_suffix_single_star() {
477 assert!(glob_match("*/notes", "alice/notes"));
478 assert!(!glob_match("*/notes", "alice/team/notes"));
479 assert!(!glob_match("*/notes", "notes"));
480 }
481
482 #[test]
483 fn glob_exact_literal() {
484 assert!(glob_match("ai-memory-mcp", "ai-memory-mcp"));
485 assert!(!glob_match("ai-memory-mcp", "ai-memory"));
486 }
487
488 // ---- namespace_allowed ----------------------------------------------
489
490 #[test]
491 fn namespace_no_header_no_bypass_denies() {
492 // Make sure no test contamination from env vars.
493 // SAFETY: the value cleared belongs to this test only;
494 // serial-by-default cargo test isolation is sufficient.
495 unsafe { std::env::remove_var(SYNC_TRUST_PEER_ENV) };
496 let cfg = PeerAttestationConfig::default();
497 assert!(!namespace_allowed(None, "any", &cfg));
498 assert!(!namespace_allowed(Some(""), "any", &cfg));
499 }
500
501 #[test]
502 fn namespace_match_via_glob() {
503 let cfg = cfg(&[(
504 "peer-1",
505 PeerScope {
506 allowed_namespaces: vec!["public/*".to_string(), "shared/team-x/**".to_string()],
507 ..PeerScope::default()
508 },
509 )]);
510 assert!(namespace_allowed(Some("peer-1"), "public/foo", &cfg));
511 assert!(namespace_allowed(Some("peer-1"), "shared/team-x/a/b", &cfg));
512 assert!(!namespace_allowed(Some("peer-1"), "private/foo", &cfg));
513 assert!(!namespace_allowed(Some("peer-1"), "public/foo/bar", &cfg));
514 }
515
516 #[test]
517 fn namespace_no_scope_row_denies_without_bypass() {
518 unsafe { std::env::remove_var(SYNC_TRUST_PEER_ENV) };
519 let cfg = PeerAttestationConfig::default();
520 assert!(!namespace_allowed(Some("peer-1"), "any", &cfg));
521 }
522
523 // ---- PeerAttestationConfig::from_env --------------------------------
524 //
525 // These three tests all mutate the process-wide PEER_ATTESTATION_ENV
526 // env var, so they MUST be serialised against each other under
527 // `cargo test --test-threads=N` (N >= 2). Without the shared mutex
528 // one test's set_var races another test's remove_var and the
529 // assertion non-deterministically observes the wrong configuration.
530 // The Coverage CI gate caught this at `--test-threads=2`:
531 // `from_env_parse_error_is_empty` saw a valid JSON payload from a
532 // concurrent `from_env_parses_valid_json` and failed
533 // `cfg.peers.is_empty()`. Same idiom as the rules-store guard.
534
535 static ENV_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
536
537 fn lock_env() -> std::sync::MutexGuard<'static, ()> {
538 ENV_GUARD
539 .lock()
540 .unwrap_or_else(std::sync::PoisonError::into_inner)
541 }
542
543 #[test]
544 fn from_env_absent_is_empty() {
545 let _g = lock_env();
546 unsafe { std::env::remove_var(PEER_ATTESTATION_ENV) };
547 let cfg = PeerAttestationConfig::from_env();
548 assert!(cfg.peers.is_empty());
549 }
550
551 #[test]
552 fn has_allowlist_false_when_zero_config_1056() {
553 // v0.7.0 #1056 — zero-config (no env var) means no allowlist,
554 // so `has_allowlist()` returns false and the federation
555 // handlers fall through to the legacy permissive posture.
556 let _g = lock_env();
557 unsafe { std::env::remove_var(PEER_ATTESTATION_ENV) };
558 let cfg = PeerAttestationConfig::from_env();
559 assert!(
560 !cfg.has_allowlist(),
561 "#1056: zero-config PeerAttestationConfig MUST report has_allowlist()=false"
562 );
563 }
564
565 #[test]
566 fn has_allowlist_true_when_peers_enrolled_1056() {
567 // v0.7.0 #1056 — once an operator enrols at least one peer,
568 // `has_allowlist()` flips to true, and the federation
569 // handlers' TOFU gate refuses any x-peer-id NOT in the map.
570 let _g = lock_env();
571 let body = r#"{"enrolled-peer": {"allowed_namespaces": ["ns/*"]}}"#;
572 unsafe { std::env::set_var(PEER_ATTESTATION_ENV, body) };
573 let cfg = PeerAttestationConfig::from_env();
574 unsafe { std::env::remove_var(PEER_ATTESTATION_ENV) };
575 assert!(
576 cfg.has_allowlist(),
577 "#1056: configured PeerAttestationConfig MUST report has_allowlist()=true"
578 );
579 assert!(cfg.scope_for("enrolled-peer").is_some());
580 assert!(
581 cfg.scope_for("not-in-map").is_none(),
582 "#1056: unknown peer MUST return None (handlers refuse)"
583 );
584 }
585
586 #[test]
587 fn from_env_parses_valid_json() {
588 let _g = lock_env();
589 let body = r#"{
590 "peer-1": {
591 "allowed_sender_agent_ids": ["alice", "bob"],
592 "allowed_namespaces": ["public/*"]
593 }
594 }"#;
595 unsafe { std::env::set_var(PEER_ATTESTATION_ENV, body) };
596 let cfg = PeerAttestationConfig::from_env();
597 unsafe { std::env::remove_var(PEER_ATTESTATION_ENV) };
598 let scope = cfg.scope_for("peer-1").expect("peer-1 row present");
599 assert_eq!(scope.allowed_sender_agent_ids, vec!["alice", "bob"]);
600 assert_eq!(scope.allowed_namespaces, vec!["public/*"]);
601 }
602
603 #[test]
604 fn from_env_parse_error_is_empty() {
605 let _g = lock_env();
606 unsafe { std::env::set_var(PEER_ATTESTATION_ENV, "not json{{") };
607 let cfg = PeerAttestationConfig::from_env();
608 unsafe { std::env::remove_var(PEER_ATTESTATION_ENV) };
609 assert!(cfg.peers.is_empty());
610 }
611}