Skip to main content

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}