Skip to main content

hirn_policy/
engine.rs

1//! Cedar-based authorization engine for hirn.
2//!
3//! Provides fine-grained RBAC + ABAC authorization using the Cedar policy
4//! language. The [`PolicyEngine`] wraps Cedar's `Authorizer`, `PolicySet`,
5//! `Schema`, and entity store to evaluate authorization requests against
6//! loaded policies.
7//!
8//! # Feature gating
9//!
10//! This module requires the `cedar` feature flag. When the feature is
11//! disabled, [`PolicyEngine::open_mode`] returns an engine that permits
12//! all requests (useful for development and testing).
13//!
14//! # Entity model
15//!
16//! ```text
17//! Agent ∈ Team ∈ Organization
18//! Namespace ∈ Realm
19//! ```
20//!
21//! Eighteen actions: `remember`, `correct`, `supersede`, `merge`,
22//! `retract`, `purge`, `recall`, `think`, `forget`, `consolidate`,
23//! `watch`, `connect`, `execute`, `admin`, `recall_raw_text`, `read`,
24//! `write`, `delete`.
25
26use std::collections::HashMap;
27use std::path::Path;
28use std::sync::Arc;
29
30use parking_lot::RwLock;
31use serde::{Deserialize, Serialize};
32
33#[cfg(feature = "cedar")]
34use cedar_policy::{
35    Authorizer, Context, Decision as CedarDecision, Entities, Entity, EntityId, EntityTypeName,
36    EntityUid, PolicySet, Request, Schema, ValidationMode,
37};
38
39use crate::error::PolicyError;
40
41/// The default Cedar schema shipped with hirn.
42pub const DEFAULT_SCHEMA: &str = include_str!("cedar/hirn.cedarschema");
43
44/// The default open-mode policy (permit all).
45pub const DEFAULT_OPEN_POLICY: &str = include_str!("cedar/default.cedar");
46
47/// Hirn authorization actions mapped to Cedar action names.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
49#[serde(rename_all = "snake_case")]
50pub enum Action {
51    Remember,
52    Correct,
53    Supersede,
54    Merge,
55    Retract,
56    Purge,
57    Recall,
58    Think,
59    Forget,
60    Consolidate,
61    Watch,
62    Connect,
63    Execute,
64    Admin,
65    RecallRawText,
66    Read,
67    Write,
68    Delete,
69}
70
71impl Action {
72    /// Cedar action entity ID string (matches the schema action names).
73    #[must_use]
74    pub const fn as_str(self) -> &'static str {
75        match self {
76            Self::Remember => "remember",
77            Self::Correct => "correct",
78            Self::Supersede => "supersede",
79            Self::Merge => "merge",
80            Self::Retract => "retract",
81            Self::Purge => "purge",
82            Self::Recall => "recall",
83            Self::Think => "think",
84            Self::Forget => "forget",
85            Self::Consolidate => "consolidate",
86            Self::Watch => "watch",
87            Self::Connect => "connect",
88            Self::Execute => "execute",
89            Self::Admin => "admin",
90            Self::RecallRawText => "recall_raw_text",
91            Self::Read => "read",
92            Self::Write => "write",
93            Self::Delete => "delete",
94        }
95    }
96}
97
98impl std::fmt::Display for Action {
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        f.write_str(self.as_str())
101    }
102}
103
104impl std::str::FromStr for Action {
105    type Err = PolicyError;
106
107    fn from_str(s: &str) -> Result<Self, Self::Err> {
108        match s.to_ascii_lowercase().as_str() {
109            "remember" => Ok(Self::Remember),
110            "correct" => Ok(Self::Correct),
111            "supersede" => Ok(Self::Supersede),
112            "merge" => Ok(Self::Merge),
113            "retract" => Ok(Self::Retract),
114            "purge" => Ok(Self::Purge),
115            "recall" => Ok(Self::Recall),
116            "think" => Ok(Self::Think),
117            "forget" => Ok(Self::Forget),
118            "consolidate" => Ok(Self::Consolidate),
119            "watch" => Ok(Self::Watch),
120            "connect" => Ok(Self::Connect),
121            "execute" => Ok(Self::Execute),
122            "admin" => Ok(Self::Admin),
123            "recall_raw_text" => Ok(Self::RecallRawText),
124            "read" => Ok(Self::Read),
125            "write" => Ok(Self::Write),
126            "delete" => Ok(Self::Delete),
127            _ => Err(PolicyError::InvalidAction(s.to_string())),
128        }
129    }
130}
131
132/// Result of an authorization check.
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct AuthzDecision {
135    /// Whether the request is allowed.
136    pub allowed: bool,
137    /// IDs of policies that contributed to the decision.
138    pub policy_ids: Vec<String>,
139    /// Human-readable reasons (from Cedar diagnostics).
140    pub reasons: Vec<String>,
141    /// Error messages from Cedar evaluation (if any).
142    pub errors: Vec<String>,
143}
144
145impl AuthzDecision {
146    /// Create an Allow decision with no diagnostics.
147    pub fn allow() -> Self {
148        Self {
149            allowed: true,
150            policy_ids: Vec::new(),
151            reasons: Vec::new(),
152            errors: Vec::new(),
153        }
154    }
155
156    /// Create a Deny decision with a reason.
157    pub fn deny(reason: impl Into<String>) -> Self {
158        Self {
159            allowed: false,
160            policy_ids: Vec::new(),
161            reasons: vec![reason.into()],
162            errors: Vec::new(),
163        }
164    }
165}
166
167/// An authorization request to evaluate.
168#[derive(Debug, Clone)]
169pub struct AuthzRequest {
170    /// The agent performing the action.
171    pub agent_id: String,
172    /// The action being performed.
173    pub action: Action,
174    /// Target realm.
175    pub realm: String,
176    /// Target namespace (empty string means realm-level).
177    pub namespace: String,
178}
179
180/// An entity registered in the policy engine's entity store.
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub enum EntityKind {
183    Agent {
184        reputation: i64,
185        created_at: String,
186        teams: Vec<String>,
187    },
188    Team {
189        description: String,
190        organization: Option<String>,
191    },
192    Organization {
193        description: String,
194    },
195    Realm {
196        description: String,
197    },
198    Namespace {
199        classification: String,
200        realm: String,
201    },
202    MemoryLayer {
203        description: String,
204    },
205    Operation {
206        description: String,
207    },
208    Tool {
209        description: String,
210    },
211}
212
213/// Thread-safe Cedar policy engine.
214///
215/// Wraps the Cedar `Authorizer` + `PolicySet` + `Schema` + entity store.
216/// All state is behind an `RwLock` so policies and entities can be updated
217/// at runtime without restart.
218pub struct PolicyEngine {
219    inner: Arc<RwLock<PolicyEngineInner>>,
220}
221
222struct PolicyEngineInner {
223    /// Whether Cedar is actually evaluating policies (false = open mode).
224    enabled: bool,
225    /// Registered entities keyed by `"Type::\"id\""` form.
226    entities: HashMap<String, EntityKind>,
227    /// Raw policy text per source (filename → text).
228    policy_sources: HashMap<String, String>,
229    /// The parsed Cedar schema text.
230    schema_text: String,
231    /// Cached Cedar objects (rebuilt on policy/entity changes).
232    #[cfg(feature = "cedar")]
233    cedar: Option<CedarState>,
234}
235
236#[derive(Clone)]
237struct PolicyEngineDraft {
238    enabled: bool,
239    entities: HashMap<String, EntityKind>,
240    policy_sources: HashMap<String, String>,
241    schema_text: String,
242}
243
244impl From<&PolicyEngineInner> for PolicyEngineDraft {
245    fn from(inner: &PolicyEngineInner) -> Self {
246        Self {
247            enabled: inner.enabled,
248            entities: inner.entities.clone(),
249            policy_sources: inner.policy_sources.clone(),
250            schema_text: inner.schema_text.clone(),
251        }
252    }
253}
254
255#[cfg(feature = "cedar")]
256struct CedarState {
257    schema: Schema,
258    policy_set: PolicySet,
259    entities: Entities,
260}
261
262impl PolicyEngine {
263    /// Create a new policy engine from schema and policy files.
264    ///
265    /// Validates the schema and all policies at construction time.
266    /// Returns an error if any schema or policy is invalid.
267    pub fn new(schema_text: &str, policies: &[(&str, &str)]) -> Result<Self, PolicyError> {
268        let mut policy_sources = HashMap::new();
269        for &(name, text) in policies {
270            policy_sources.insert(name.to_string(), text.to_string());
271        }
272
273        let inner = PolicyEngineInner {
274            enabled: true,
275            entities: HashMap::new(),
276            policy_sources,
277            schema_text: schema_text.to_string(),
278            #[cfg(feature = "cedar")]
279            cedar: None,
280        };
281
282        let engine = Self {
283            inner: Arc::new(RwLock::new(inner)),
284        };
285
286        #[cfg(feature = "cedar")]
287        engine.rebuild_cedar()?;
288
289        Ok(engine)
290    }
291
292    /// Create a policy engine in open mode: all requests are allowed.
293    ///
294    /// Used when the `cedar` feature is disabled or for development/testing.
295    ///
296    /// # Warning
297    ///
298    /// **Open mode is unsafe for production.** Every authorization request is
299    /// permitted without any policy evaluation.  A `tracing::error!` is emitted
300    /// at construction time so that production monitoring surfaces the
301    /// misconfiguration.  Configure `policies_dir` to enable Cedar evaluation.
302    #[must_use]
303    pub fn open_mode() -> Self {
304        tracing::error!(
305            "PolicyEngine::open_mode — ALL authorization requests PERMITTED without evaluation. \
306             This is NOT safe for production. Set a `policies_dir` to enable Cedar policy enforcement."
307        );
308        let inner = PolicyEngineInner {
309            enabled: false,
310            entities: HashMap::new(),
311            policy_sources: HashMap::new(),
312            schema_text: String::new(),
313            #[cfg(feature = "cedar")]
314            cedar: None,
315        };
316
317        Self {
318            inner: Arc::new(RwLock::new(inner)),
319        }
320    }
321
322    /// Load policies from a brain directory.
323    ///
324    /// Reads `{brain_dir}/policies/hirn.cedarschema` (or uses default schema)
325    /// and all `*.cedar` files in `{brain_dir}/policies/`.
326    ///
327    /// Fails closed when no policy files are present. Use
328    /// [`Self::load_from_brain_insecure_dev_mode`] to explicitly opt into the
329    /// built-in permit-all development policy.
330    pub fn load_from_brain(brain_dir: &Path) -> Result<Self, PolicyError> {
331        Self::load_from_brain_inner(brain_dir, false)
332    }
333
334    /// Load policies from a brain directory, permitting the built-in default
335    /// open policy when no `*.cedar` files are present.
336    ///
337    /// This is intended only for explicit development/test posture. A
338    /// `tracing::error!` is emitted to surface this misconfiguration.
339    pub fn load_from_brain_insecure_dev_mode(brain_dir: &Path) -> Result<Self, PolicyError> {
340        tracing::error!(
341            brain_dir = %brain_dir.display(),
342            "PolicyEngine::load_from_brain_insecure_dev_mode — falling back to OPEN mode when \
343             no Cedar policies are found. This is NOT safe for production."
344        );
345        Self::load_from_brain_inner(brain_dir, true)
346    }
347
348    fn load_from_brain_inner(
349        brain_dir: &Path,
350        allow_default_open_policy: bool,
351    ) -> Result<Self, PolicyError> {
352        let policies_dir = brain_dir.join("policies");
353
354        let schema_path = policies_dir.join("hirn.cedarschema");
355        let schema_text = if schema_path.exists() {
356            std::fs::read_to_string(&schema_path).map_err(|e| PolicyError::Io {
357                path: schema_path.display().to_string(),
358                reason: e.to_string(),
359            })?
360        } else {
361            DEFAULT_SCHEMA.to_string()
362        };
363
364        let mut policy_files: Vec<(String, String)> = Vec::new();
365        if policies_dir.exists() {
366            let entries = std::fs::read_dir(&policies_dir).map_err(|e| PolicyError::Io {
367                path: policies_dir.display().to_string(),
368                reason: e.to_string(),
369            })?;
370            for entry in entries {
371                let entry = entry.map_err(|e| PolicyError::Io {
372                    path: policies_dir.display().to_string(),
373                    reason: e.to_string(),
374                })?;
375                let path = entry.path();
376                if path.extension().is_some_and(|ext| ext == "cedar") {
377                    let name = path
378                        .file_name()
379                        .unwrap_or_default()
380                        .to_string_lossy()
381                        .to_string();
382                    let text = std::fs::read_to_string(&path).map_err(|e| PolicyError::Io {
383                        path: path.display().to_string(),
384                        reason: e.to_string(),
385                    })?;
386                    policy_files.push((name, text));
387                }
388            }
389        }
390
391        if policy_files.is_empty() {
392            if !allow_default_open_policy {
393                return Err(PolicyError::MissingPolicies {
394                    path: policies_dir.display().to_string(),
395                });
396            }
397            policy_files.push(("default.cedar".to_string(), DEFAULT_OPEN_POLICY.to_string()));
398        }
399
400        let refs: Vec<(&str, &str)> = policy_files
401            .iter()
402            .map(|(n, t)| (n.as_str(), t.as_str()))
403            .collect();
404
405        Self::new(&schema_text, &refs)
406    }
407
408    /// Check whether this engine is in open mode (all requests allowed).
409    #[must_use]
410    pub fn is_open_mode(&self) -> bool {
411        !self.inner.read().enabled
412    }
413
414    /// Whether Cedar policy evaluation is active (not open mode).
415    #[must_use]
416    pub fn is_enabled(&self) -> bool {
417        self.inner.read().enabled
418    }
419
420    /// Returns the number of loaded policies.
421    #[must_use]
422    pub fn policy_count(&self) -> usize {
423        #[cfg(feature = "cedar")]
424        {
425            let guard = self.inner.read();
426            guard
427                .cedar
428                .as_ref()
429                .map_or(0, |c| c.policy_set.policies().count())
430        }
431        #[cfg(not(feature = "cedar"))]
432        {
433            0
434        }
435    }
436
437    /// Returns the number of registered entities.
438    #[must_use]
439    pub fn entity_count(&self) -> usize {
440        self.inner.read().entities.len()
441    }
442
443    /// List all registered namespace IDs and their associated realms.
444    #[must_use]
445    pub fn registered_namespaces(&self) -> Vec<(String, String)> {
446        let guard = self.inner.read();
447        guard
448            .entities
449            .iter()
450            .filter_map(|(key, kind)| {
451                if let EntityKind::Namespace { realm, .. } = kind {
452                    let id = key
453                        .strip_prefix("Hirn::Namespace::\"")
454                        .and_then(|s| s.strip_suffix('"'))
455                        .unwrap_or(key);
456                    Some((id.to_string(), realm.clone()))
457                } else {
458                    None
459                }
460            })
461            .collect()
462    }
463
464    fn update_state<R>(
465        &self,
466        mutate: impl FnOnce(&mut PolicyEngineDraft) -> R,
467    ) -> Result<R, PolicyError> {
468        let mut guard = self.inner.write();
469        let mut draft = PolicyEngineDraft::from(&*guard);
470        let result = mutate(&mut draft);
471
472        #[cfg(feature = "cedar")]
473        let cedar = if draft.enabled {
474            Some(Self::build_cedar_state(
475                &draft.schema_text,
476                &draft.policy_sources,
477                &draft.entities,
478            )?)
479        } else {
480            None
481        };
482
483        guard.enabled = draft.enabled;
484        guard.entities = draft.entities;
485        guard.policy_sources = draft.policy_sources;
486        guard.schema_text = draft.schema_text;
487
488        #[cfg(feature = "cedar")]
489        {
490            guard.cedar = cedar;
491        }
492
493        Ok(result)
494    }
495
496    /// List all policy source names and their raw Cedar text.
497    #[must_use]
498    pub fn list_policies(&self) -> Vec<(String, String)> {
499        let guard = self.inner.read();
500        let mut policies: Vec<(String, String)> = guard
501            .policy_sources
502            .iter()
503            .map(|(k, v)| (k.clone(), v.clone()))
504            .collect();
505        policies.sort_by(|a, b| a.0.cmp(&b.0));
506        policies
507    }
508
509    /// Authorize a request against loaded policies.
510    ///
511    /// Returns an [`AuthzDecision`] indicating whether the request is allowed
512    /// or denied, along with diagnostic information.
513    pub fn authorize(&self, request: &AuthzRequest) -> AuthzDecision {
514        let guard = self.inner.read();
515
516        if !guard.enabled {
517            return AuthzDecision::allow();
518        }
519
520        #[cfg(feature = "cedar")]
521        {
522            self.authorize_cedar(&guard, request)
523        }
524        #[cfg(not(feature = "cedar"))]
525        {
526            let _ = &guard;
527            let _ = request;
528            AuthzDecision::allow()
529        }
530    }
531
532    /// Resolve which namespaces an agent can access for a given action.
533    ///
534    /// Returns `None` if engine is in open mode (permit all).
535    /// Returns `Some(vec![...])` with allowed namespace IDs otherwise.
536    pub fn allowed_namespaces_for(&self, agent_id: &str, action: Action) -> Option<Vec<String>> {
537        if !self.is_enabled() {
538            return None;
539        }
540
541        let namespaces = self.registered_namespaces();
542        let mut allowed = Vec::new();
543        for (ns_id, realm) in &namespaces {
544            let decision = self.authorize(&AuthzRequest {
545                agent_id: agent_id.to_string(),
546                action,
547                realm: realm.clone(),
548                namespace: ns_id.clone(),
549            });
550            if decision.allowed {
551                allowed.push(ns_id.clone());
552            }
553        }
554        Some(allowed)
555    }
556
557    // ── Entity management ────────────────────────────────────────────
558
559    /// Register an agent entity.
560    pub fn register_agent(
561        &self,
562        agent_id: &str,
563        reputation: i64,
564        created_at: &str,
565        teams: &[&str],
566    ) -> Result<(), PolicyError> {
567        let key = format!("Hirn::Agent::\"{}\"", agent_id);
568        let entity = EntityKind::Agent {
569            reputation,
570            created_at: created_at.to_string(),
571            teams: teams.iter().map(|s| (*s).to_string()).collect(),
572        };
573        self.update_state(move |draft| {
574            draft.entities.insert(key, entity);
575        })
576    }
577
578    /// Register a team entity.
579    pub fn register_team(
580        &self,
581        team_id: &str,
582        description: &str,
583        organization: Option<&str>,
584    ) -> Result<(), PolicyError> {
585        let key = format!("Hirn::Team::\"{}\"", team_id);
586        let entity = EntityKind::Team {
587            description: description.to_string(),
588            organization: organization.map(String::from),
589        };
590        self.update_state(move |draft| {
591            draft.entities.insert(key, entity);
592        })
593    }
594
595    /// Register an organization entity.
596    pub fn register_organization(
597        &self,
598        org_id: &str,
599        description: &str,
600    ) -> Result<(), PolicyError> {
601        let key = format!("Hirn::Organization::\"{}\"", org_id);
602        let entity = EntityKind::Organization {
603            description: description.to_string(),
604        };
605        self.update_state(move |draft| {
606            draft.entities.insert(key, entity);
607        })
608    }
609
610    /// Register a realm entity.
611    pub fn register_realm(&self, realm_id: &str, description: &str) -> Result<(), PolicyError> {
612        let key = format!("Hirn::Realm::\"{}\"", realm_id);
613        let entity = EntityKind::Realm {
614            description: description.to_string(),
615        };
616        self.update_state(move |draft| {
617            draft.entities.insert(key, entity);
618        })
619    }
620
621    /// Register a namespace entity.
622    pub fn register_namespace(
623        &self,
624        namespace_id: &str,
625        classification: &str,
626        realm: &str,
627    ) -> Result<(), PolicyError> {
628        let key = format!("Hirn::Namespace::\"{}\"", namespace_id);
629        let entity = EntityKind::Namespace {
630            classification: classification.to_string(),
631            realm: realm.to_string(),
632        };
633        self.update_state(move |draft| {
634            draft.entities.insert(key, entity);
635        })
636    }
637
638    /// Register a memory layer entity (Working, Episodic, Semantic, Procedural).
639    pub fn register_memory_layer(
640        &self,
641        layer_id: &str,
642        description: &str,
643    ) -> Result<(), PolicyError> {
644        let key = format!("Hirn::MemoryLayer::\"{}\"", layer_id);
645        let entity = EntityKind::MemoryLayer {
646            description: description.to_string(),
647        };
648        self.update_state(move |draft| {
649            draft.entities.insert(key, entity);
650        })
651    }
652
653    /// Register an operation entity (Recall, Think, Remember, etc.).
654    pub fn register_operation(
655        &self,
656        operation_id: &str,
657        description: &str,
658    ) -> Result<(), PolicyError> {
659        let key = format!("Hirn::Operation::\"{}\"", operation_id);
660        let entity = EntityKind::Operation {
661            description: description.to_string(),
662        };
663        self.update_state(move |draft| {
664            draft.entities.insert(key, entity);
665        })
666    }
667
668    /// Register a tool entity for MCP tool-level access control.
669    pub fn register_tool(&self, tool_id: &str, description: &str) -> Result<(), PolicyError> {
670        let key = format!("Hirn::Tool::\"{}\"", tool_id);
671        let entity = EntityKind::Tool {
672            description: description.to_string(),
673        };
674        self.update_state(move |draft| {
675            draft.entities.insert(key, entity);
676        })
677    }
678
679    /// Remove an entity by its Cedar key (e.g. `Hirn::Agent::"agent-007"`).
680    pub fn remove_entity(&self, key: &str) -> Result<bool, PolicyError> {
681        self.update_state(|draft| draft.entities.remove(key).is_some())
682    }
683
684    // ── Policy management ────────────────────────────────────────────
685
686    /// Add or replace a policy source.
687    pub fn add_policy(&self, name: &str, policy_text: &str) -> Result<(), PolicyError> {
688        self.add_policies(&[(name, policy_text)])
689    }
690
691    /// Atomically add or replace multiple policy sources.
692    pub fn add_policies(&self, policies: &[(&str, &str)]) -> Result<(), PolicyError> {
693        self.update_state(|draft| {
694            for &(name, text) in policies {
695                draft
696                    .policy_sources
697                    .insert(name.to_string(), text.to_string());
698            }
699        })
700    }
701
702    /// Remove a policy source by name.
703    pub fn remove_policy(&self, name: &str) -> Result<bool, PolicyError> {
704        self.update_state(|draft| draft.policy_sources.remove(name).is_some())
705    }
706
707    /// Validate the current schema against all loaded policies.
708    pub fn validate(&self) -> Vec<String> {
709        #[cfg(feature = "cedar")]
710        {
711            let guard = self.inner.read();
712            if let Some(cedar) = &guard.cedar {
713                let validator = cedar_policy::Validator::new(cedar.schema.clone());
714                let result = validator.validate(&cedar.policy_set, ValidationMode::default());
715                let mut messages = Vec::new();
716                for note in result.validation_errors() {
717                    messages.push(format!("error: {note}"));
718                }
719                for note in result.validation_warnings() {
720                    messages.push(format!("warning: {note}"));
721                }
722                messages
723            } else {
724                Vec::new()
725            }
726        }
727        #[cfg(not(feature = "cedar"))]
728        {
729            Vec::new()
730        }
731    }
732
733    /// Save the current policies and schema to a brain directory.
734    pub fn save_to_brain(&self, brain_dir: &Path) -> Result<(), PolicyError> {
735        let policies_dir = brain_dir.join("policies");
736        std::fs::create_dir_all(&policies_dir).map_err(|e| PolicyError::Io {
737            path: policies_dir.display().to_string(),
738            reason: e.to_string(),
739        })?;
740
741        let guard = self.inner.read();
742
743        let schema_path = policies_dir.join("hirn.cedarschema");
744        std::fs::write(&schema_path, &guard.schema_text).map_err(|e| PolicyError::Io {
745            path: schema_path.display().to_string(),
746            reason: e.to_string(),
747        })?;
748
749        for (name, text) in &guard.policy_sources {
750            let policy_path = policies_dir.join(name);
751            std::fs::write(&policy_path, text).map_err(|e| PolicyError::Io {
752                path: policy_path.display().to_string(),
753                reason: e.to_string(),
754            })?;
755        }
756
757        Ok(())
758    }
759
760    // ── Cedar internals ──────────────────────────────────────────────
761
762    #[cfg(feature = "cedar")]
763    fn rebuild_cedar(&self) -> Result<(), PolicyError> {
764        let mut guard = self.inner.write();
765
766        guard.cedar = if guard.enabled {
767            Some(Self::build_cedar_state(
768                &guard.schema_text,
769                &guard.policy_sources,
770                &guard.entities,
771            )?)
772        } else {
773            None
774        };
775
776        Ok(())
777    }
778
779    #[cfg(feature = "cedar")]
780    fn build_cedar_state(
781        schema_text: &str,
782        policy_sources: &HashMap<String, String>,
783        entities: &HashMap<String, EntityKind>,
784    ) -> Result<CedarState, PolicyError> {
785        let schema = schema_text
786            .parse::<Schema>()
787            .map_err(|e| PolicyError::SchemaInvalid(format!("{e}")))?;
788
789        let combined_text: String = policy_sources
790            .iter()
791            .map(|(name, text)| format!("// source: {name}\n{text}\n"))
792            .collect();
793
794        let policy_set =
795            combined_text
796                .parse::<PolicySet>()
797                .map_err(|e| PolicyError::PolicyInvalid {
798                    name: "combined".to_string(),
799                    detail: format!("{e}"),
800                })?;
801
802        let entities = Self::build_entities(entities, &schema)?;
803
804        Ok(CedarState {
805            schema,
806            policy_set,
807            entities,
808        })
809    }
810
811    #[cfg(feature = "cedar")]
812    fn authorize_cedar(&self, guard: &PolicyEngineInner, request: &AuthzRequest) -> AuthzDecision {
813        let cedar = match &guard.cedar {
814            Some(c) => c,
815            None => return AuthzDecision::deny("policy engine not initialized"),
816        };
817
818        let principal = EntityUid::from_type_name_and_id(
819            Self::parse_type_name("Hirn::Agent"),
820            EntityId::new(request.agent_id.clone()),
821        );
822
823        let action = EntityUid::from_type_name_and_id(
824            Self::parse_type_name("Hirn::Action"),
825            EntityId::new(request.action.as_str()),
826        );
827
828        let resource = if request.namespace.is_empty() {
829            EntityUid::from_type_name_and_id(
830                Self::parse_type_name("Hirn::Realm"),
831                EntityId::new(request.realm.clone()),
832            )
833        } else {
834            EntityUid::from_type_name_and_id(
835                Self::parse_type_name("Hirn::Namespace"),
836                EntityId::new(request.namespace.clone()),
837            )
838        };
839
840        let context = Context::empty();
841
842        let cedar_request =
843            match Request::new(principal, action, resource, context, Some(&cedar.schema)) {
844                Ok(r) => r,
845                Err(e) => return AuthzDecision::deny(format!("invalid request: {e}")),
846            };
847
848        let authorizer = Authorizer::new();
849        let response = authorizer.is_authorized(&cedar_request, &cedar.policy_set, &cedar.entities);
850
851        let mut decision = AuthzDecision {
852            allowed: response.decision() == CedarDecision::Allow,
853            policy_ids: response
854                .diagnostics()
855                .reason()
856                .map(|id| id.to_string())
857                .collect(),
858            reasons: Vec::new(),
859            errors: response
860                .diagnostics()
861                .errors()
862                .map(|e| e.to_string())
863                .collect(),
864        };
865
866        if !decision.allowed {
867            decision.reasons.push(format!(
868                "denied: {} cannot {} on {}",
869                request.agent_id,
870                request.action,
871                if request.namespace.is_empty() {
872                    &request.realm
873                } else {
874                    &request.namespace
875                }
876            ));
877        }
878
879        decision
880    }
881
882    #[cfg(feature = "cedar")]
883    fn parse_type_name(name: &str) -> EntityTypeName {
884        name.parse().expect("valid Cedar entity type name")
885    }
886
887    #[cfg(feature = "cedar")]
888    fn build_entities(
889        entity_map: &HashMap<String, EntityKind>,
890        schema: &Schema,
891    ) -> Result<Entities, PolicyError> {
892        let mut entities_vec: Vec<Entity> = Vec::new();
893
894        for (key, kind) in entity_map {
895            let entity = Self::build_entity(key, kind)?;
896            entities_vec.push(entity);
897        }
898
899        Entities::from_entities(entities_vec, Some(schema))
900            .map_err(|e| PolicyError::EntityInvalid(format!("{e}")))
901    }
902
903    #[cfg(feature = "cedar")]
904    fn build_entity(key: &str, kind: &EntityKind) -> Result<Entity, PolicyError> {
905        use cedar_policy::RestrictedExpression;
906
907        let (type_str, id_str) = Self::parse_entity_key(key)?;
908        let uid = EntityUid::from_type_name_and_id(
909            Self::parse_type_name(type_str),
910            EntityId::new(id_str),
911        );
912
913        match kind {
914            EntityKind::Agent {
915                reputation,
916                created_at,
917                teams,
918            } => {
919                let parents: Vec<EntityUid> = teams
920                    .iter()
921                    .map(|t| {
922                        EntityUid::from_type_name_and_id(
923                            Self::parse_type_name("Hirn::Team"),
924                            EntityId::new(t.as_str()),
925                        )
926                    })
927                    .collect();
928
929                let attrs = HashMap::from([
930                    (
931                        "reputation".to_string(),
932                        RestrictedExpression::new_long(*reputation),
933                    ),
934                    (
935                        "created_at".to_string(),
936                        RestrictedExpression::new_string(created_at.clone()),
937                    ),
938                ]);
939
940                Ok(Entity::new(uid, attrs, parents.into_iter().collect())
941                    .map_err(|e| PolicyError::EntityInvalid(format!("{e}")))?)
942            }
943            EntityKind::Team {
944                description,
945                organization,
946            } => {
947                let parents: Vec<EntityUid> = organization
948                    .iter()
949                    .map(|o| {
950                        EntityUid::from_type_name_and_id(
951                            Self::parse_type_name("Hirn::Organization"),
952                            EntityId::new(o.as_str()),
953                        )
954                    })
955                    .collect();
956
957                let attrs = HashMap::from([(
958                    "description".to_string(),
959                    RestrictedExpression::new_string(description.clone()),
960                )]);
961
962                Ok(Entity::new(uid, attrs, parents.into_iter().collect())
963                    .map_err(|e| PolicyError::EntityInvalid(format!("{e}")))?)
964            }
965            EntityKind::Organization { description } => {
966                let attrs = HashMap::from([(
967                    "description".to_string(),
968                    RestrictedExpression::new_string(description.clone()),
969                )]);
970
971                Ok(Entity::new(uid, attrs, [].into_iter().collect())
972                    .map_err(|e| PolicyError::EntityInvalid(format!("{e}")))?)
973            }
974            EntityKind::Realm { description } => {
975                let attrs = HashMap::from([(
976                    "description".to_string(),
977                    RestrictedExpression::new_string(description.clone()),
978                )]);
979
980                Ok(Entity::new(uid, attrs, [].into_iter().collect())
981                    .map_err(|e| PolicyError::EntityInvalid(format!("{e}")))?)
982            }
983            EntityKind::Namespace {
984                classification,
985                realm,
986            } => {
987                let parents = vec![EntityUid::from_type_name_and_id(
988                    Self::parse_type_name("Hirn::Realm"),
989                    EntityId::new(realm.as_str()),
990                )];
991
992                let attrs = HashMap::from([(
993                    "classification".to_string(),
994                    RestrictedExpression::new_string(classification.clone()),
995                )]);
996
997                Ok(Entity::new(uid, attrs, parents.into_iter().collect())
998                    .map_err(|e| PolicyError::EntityInvalid(format!("{e}")))?)
999            }
1000            EntityKind::MemoryLayer { description }
1001            | EntityKind::Operation { description }
1002            | EntityKind::Tool { description } => {
1003                let attrs = HashMap::from([(
1004                    "description".to_string(),
1005                    RestrictedExpression::new_string(description.clone()),
1006                )]);
1007
1008                Ok(Entity::new(uid, attrs, [].into_iter().collect())
1009                    .map_err(|e| PolicyError::EntityInvalid(format!("{e}")))?)
1010            }
1011        }
1012    }
1013
1014    /// Parse `Hirn::Agent::"agent-007"` → `("Hirn::Agent", "agent-007")`
1015    #[cfg(feature = "cedar")]
1016    fn parse_entity_key(key: &str) -> Result<(&str, &str), PolicyError> {
1017        if let Some(idx) = key.rfind("::\"") {
1018            let type_str = &key[..idx];
1019            let id_raw = &key[idx + 2..];
1020            let id_str = id_raw.trim_matches('"');
1021            Ok((type_str, id_str))
1022        } else {
1023            Err(PolicyError::EntityInvalid(format!(
1024                "invalid entity key format: {key}"
1025            )))
1026        }
1027    }
1028}
1029
1030impl Clone for PolicyEngine {
1031    fn clone(&self) -> Self {
1032        Self {
1033            inner: Arc::clone(&self.inner),
1034        }
1035    }
1036}
1037
1038impl std::fmt::Debug for PolicyEngine {
1039    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1040        let guard = self.inner.read();
1041        f.debug_struct("PolicyEngine")
1042            .field("enabled", &guard.enabled)
1043            .field("entities", &guard.entities.len())
1044            .field("policy_sources", &guard.policy_sources.len())
1045            .finish()
1046    }
1047}
1048
1049// ── Tests ───────────────────────────────────────────────────────────────
1050
1051#[cfg(test)]
1052mod tests {
1053    use super::*;
1054
1055    #[test]
1056    fn valid_schema_parses() {
1057        let engine = PolicyEngine::new(DEFAULT_SCHEMA, &[("default.cedar", DEFAULT_OPEN_POLICY)]);
1058        assert!(engine.is_ok(), "default schema should parse: {engine:?}");
1059    }
1060
1061    #[test]
1062    fn invalid_schema_returns_error() {
1063        let bad_schema = "this is not a valid cedar schema!!!";
1064        let result = PolicyEngine::new(bad_schema, &[("default.cedar", DEFAULT_OPEN_POLICY)]);
1065        assert!(result.is_err());
1066        match result.unwrap_err() {
1067            PolicyError::SchemaInvalid(msg) => {
1068                assert!(!msg.is_empty());
1069            }
1070            other => panic!("expected SchemaInvalid, got: {other:?}"),
1071        }
1072    }
1073
1074    #[test]
1075    fn schema_covers_all_actions() {
1076        for action in [
1077            "remember",
1078            "correct",
1079            "supersede",
1080            "merge",
1081            "retract",
1082            "purge",
1083            "recall",
1084            "think",
1085            "forget",
1086            "consolidate",
1087            "watch",
1088            "connect",
1089            "execute",
1090            "admin",
1091            "recall_raw_text",
1092            "read",
1093            "write",
1094            "delete",
1095        ] {
1096            assert!(
1097                DEFAULT_SCHEMA.contains(action),
1098                "schema should include action '{action}'"
1099            );
1100        }
1101    }
1102
1103    #[test]
1104    fn action_strings_round_trip() {
1105        for action in [
1106            "remember",
1107            "correct",
1108            "supersede",
1109            "merge",
1110            "retract",
1111            "purge",
1112            "recall",
1113            "think",
1114            "forget",
1115            "consolidate",
1116            "watch",
1117            "connect",
1118            "execute",
1119            "admin",
1120            "recall_raw_text",
1121            "read",
1122            "write",
1123            "delete",
1124        ] {
1125            let parsed: Action = action.parse().unwrap();
1126            assert_eq!(parsed.as_str(), action);
1127        }
1128    }
1129
1130    #[test]
1131    fn open_mode_allows_everything() {
1132        let engine = PolicyEngine::open_mode();
1133        assert!(engine.is_open_mode());
1134
1135        let decision = engine.authorize(&AuthzRequest {
1136            agent_id: "any-agent".to_string(),
1137            action: Action::Remember,
1138            realm: "any-realm".to_string(),
1139            namespace: String::new(),
1140        });
1141        assert!(decision.allowed);
1142    }
1143
1144    #[test]
1145    fn default_open_policy_allows_everything() {
1146        let engine =
1147            PolicyEngine::new(DEFAULT_SCHEMA, &[("default.cedar", DEFAULT_OPEN_POLICY)]).unwrap();
1148
1149        engine
1150            .register_agent("test-agent", 100, "2025-01-01T00:00:00Z", &[])
1151            .unwrap();
1152        engine.register_realm("test-realm", "Test").unwrap();
1153
1154        let decision = engine.authorize(&AuthzRequest {
1155            agent_id: "test-agent".to_string(),
1156            action: Action::Remember,
1157            realm: "test-realm".to_string(),
1158            namespace: String::new(),
1159        });
1160        assert!(decision.allowed, "open policy should allow: {decision:?}");
1161    }
1162
1163    #[test]
1164    fn team_policy_allows_members_denies_others() {
1165        let policy = r#"
1166            permit(
1167                principal in Hirn::Team::"writers",
1168                action == Hirn::Action::"remember",
1169                resource == Hirn::Realm::"production"
1170            );
1171        "#;
1172
1173        let engine = PolicyEngine::new(DEFAULT_SCHEMA, &[("acl.cedar", policy)]).unwrap();
1174
1175        engine
1176            .register_team("writers", "Writer team", None)
1177            .unwrap();
1178        engine.register_realm("production", "Prod").unwrap();
1179
1180        engine
1181            .register_agent("alice", 100, "2025-01-01T00:00:00Z", &["writers"])
1182            .unwrap();
1183        let decision = engine.authorize(&AuthzRequest {
1184            agent_id: "alice".to_string(),
1185            action: Action::Remember,
1186            realm: "production".to_string(),
1187            namespace: String::new(),
1188        });
1189        assert!(decision.allowed, "alice should be allowed: {decision:?}");
1190
1191        engine
1192            .register_agent("bob", 100, "2025-01-01T00:00:00Z", &[])
1193            .unwrap();
1194        let decision = engine.authorize(&AuthzRequest {
1195            agent_id: "bob".to_string(),
1196            action: Action::Remember,
1197            realm: "production".to_string(),
1198            namespace: String::new(),
1199        });
1200        assert!(!decision.allowed, "bob should be denied: {decision:?}");
1201    }
1202
1203    #[test]
1204    fn abac_reputation_constraint() {
1205        let policy = r#"
1206            permit(
1207                principal,
1208                action == Hirn::Action::"remember",
1209                resource
1210            ) when { principal.reputation >= 50 };
1211        "#;
1212
1213        let engine = PolicyEngine::new(DEFAULT_SCHEMA, &[("acl.cedar", policy)]).unwrap();
1214        engine.register_realm("test", "Test").unwrap();
1215
1216        engine
1217            .register_agent("good-agent", 100, "2025-01-01T00:00:00Z", &[])
1218            .unwrap();
1219        let decision = engine.authorize(&AuthzRequest {
1220            agent_id: "good-agent".to_string(),
1221            action: Action::Remember,
1222            realm: "test".to_string(),
1223            namespace: String::new(),
1224        });
1225        assert!(decision.allowed, "high rep allowed: {decision:?}");
1226
1227        engine
1228            .register_agent("bad-agent", 10, "2025-01-01T00:00:00Z", &[])
1229            .unwrap();
1230        let decision = engine.authorize(&AuthzRequest {
1231            agent_id: "bad-agent".to_string(),
1232            action: Action::Remember,
1233            realm: "test".to_string(),
1234            namespace: String::new(),
1235        });
1236        assert!(!decision.allowed, "low rep denied: {decision:?}");
1237    }
1238
1239    #[test]
1240    fn save_and_load_from_brain() {
1241        let temp = tempfile::tempdir().unwrap();
1242        let brain_dir = temp.path();
1243
1244        let custom_policy = r#"
1245            permit(
1246                principal in Hirn::Team::"writers",
1247                action == Hirn::Action::"remember",
1248                resource
1249            );
1250        "#;
1251        let engine = PolicyEngine::new(DEFAULT_SCHEMA, &[("custom.cedar", custom_policy)]).unwrap();
1252        engine.save_to_brain(brain_dir).unwrap();
1253
1254        assert!(brain_dir.join("policies/hirn.cedarschema").exists());
1255        assert!(brain_dir.join("policies/custom.cedar").exists());
1256
1257        let loaded = PolicyEngine::load_from_brain(brain_dir).unwrap();
1258        assert!(loaded.policy_count() >= 1);
1259    }
1260
1261    #[test]
1262    fn invalid_policy_add_rolls_back_policy_sources() {
1263        let engine =
1264            PolicyEngine::new(DEFAULT_SCHEMA, &[("default.cedar", DEFAULT_OPEN_POLICY)]).unwrap();
1265        let before = engine.list_policies();
1266
1267        let err = engine
1268            .add_policy("broken.cedar", "this is not valid cedar")
1269            .unwrap_err();
1270        assert!(matches!(err, PolicyError::PolicyInvalid { .. }));
1271        assert_eq!(engine.list_policies(), before);
1272    }
1273
1274    #[test]
1275    fn invalid_policy_remove_rolls_back_policy_sources() {
1276        let engine = PolicyEngine::new(
1277            DEFAULT_SCHEMA,
1278            &[
1279                ("default.cedar", DEFAULT_OPEN_POLICY),
1280                ("extra.cedar", DEFAULT_OPEN_POLICY),
1281            ],
1282        )
1283        .unwrap();
1284
1285        {
1286            let mut guard = engine.inner.write();
1287            guard.schema_text = "this is not a valid cedar schema!!!".to_string();
1288        }
1289
1290        let err = engine.remove_policy("extra.cedar").unwrap_err();
1291        assert!(matches!(err, PolicyError::SchemaInvalid(_)));
1292        assert!(
1293            engine
1294                .list_policies()
1295                .iter()
1296                .any(|(name, _)| name == "extra.cedar")
1297        );
1298    }
1299
1300    #[test]
1301    fn invalid_entity_registration_rolls_back_entities() {
1302        let engine =
1303            PolicyEngine::new(DEFAULT_SCHEMA, &[("default.cedar", DEFAULT_OPEN_POLICY)]).unwrap();
1304        let before = engine.entity_count();
1305
1306        {
1307            let mut guard = engine.inner.write();
1308            guard.schema_text = "this is not a valid cedar schema!!!".to_string();
1309        }
1310
1311        let err = engine
1312            .register_namespace("candidate-ns", "public", "candidate-realm")
1313            .unwrap_err();
1314        assert!(matches!(err, PolicyError::SchemaInvalid(_)));
1315        assert_eq!(engine.entity_count(), before);
1316        assert!(engine.registered_namespaces().is_empty());
1317    }
1318
1319    #[test]
1320    fn load_from_brain_without_policies_fails_closed() {
1321        let temp = tempfile::tempdir().unwrap();
1322        let err = PolicyEngine::load_from_brain(temp.path()).unwrap_err();
1323        assert!(matches!(err, PolicyError::MissingPolicies { .. }));
1324    }
1325
1326    #[test]
1327    fn load_from_brain_insecure_dev_mode_uses_default_open_policy() {
1328        let temp = tempfile::tempdir().unwrap();
1329        let loaded = PolicyEngine::load_from_brain_insecure_dev_mode(temp.path()).unwrap();
1330        assert!(loaded.policy_count() >= 1);
1331        assert!(!loaded.is_open_mode());
1332    }
1333
1334    #[test]
1335    fn allowed_namespaces_open_mode() {
1336        let engine = PolicyEngine::open_mode();
1337        let result = engine.allowed_namespaces_for("anyone", Action::Recall);
1338        assert!(result.is_none());
1339    }
1340
1341    #[test]
1342    fn allowed_namespaces_filters() {
1343        let engine =
1344            PolicyEngine::new(DEFAULT_SCHEMA, &[("default.cedar", DEFAULT_OPEN_POLICY)]).unwrap();
1345        engine.register_realm("production", "prod").unwrap();
1346        engine
1347            .register_namespace("ns_a", "public", "production")
1348            .unwrap();
1349        engine
1350            .register_namespace("ns_b", "public", "production")
1351            .unwrap();
1352        engine
1353            .register_agent("agent-1", 50, "2024-01-01", &[])
1354            .unwrap();
1355
1356        let result = engine.allowed_namespaces_for("agent-1", Action::Recall);
1357        assert!(result.is_some());
1358        let mut allowed = result.unwrap();
1359        allowed.sort();
1360        assert_eq!(allowed, vec!["ns_a", "ns_b"]);
1361    }
1362
1363    #[test]
1364    fn concurrent_authorization() {
1365        use std::sync::Arc;
1366        use std::thread;
1367
1368        let engine = Arc::new(
1369            PolicyEngine::new(DEFAULT_SCHEMA, &[("default.cedar", DEFAULT_OPEN_POLICY)]).unwrap(),
1370        );
1371
1372        engine
1373            .register_agent("concurrent-agent", 100, "2025-01-01T00:00:00Z", &[])
1374            .unwrap();
1375        engine.register_realm("test", "Test").unwrap();
1376
1377        let handles: Vec<_> = (0..100)
1378            .map(|_| {
1379                let eng = Arc::clone(&engine);
1380                thread::spawn(move || {
1381                    let d = eng.authorize(&AuthzRequest {
1382                        agent_id: "concurrent-agent".to_string(),
1383                        action: Action::Recall,
1384                        realm: "test".to_string(),
1385                        namespace: String::new(),
1386                    });
1387                    assert!(d.allowed);
1388                })
1389            })
1390            .collect();
1391
1392        for h in handles {
1393            h.join().unwrap();
1394        }
1395    }
1396
1397    #[test]
1398    fn schema_includes_entity_types() {
1399        for entity in ["MemoryLayer", "Operation", "Tool"] {
1400            assert!(
1401                DEFAULT_SCHEMA.contains(entity),
1402                "schema should include entity '{entity}'"
1403            );
1404        }
1405    }
1406
1407    #[test]
1408    fn register_memory_layer_entity() {
1409        let engine =
1410            PolicyEngine::new(DEFAULT_SCHEMA, &[("default.cedar", DEFAULT_OPEN_POLICY)]).unwrap();
1411        engine
1412            .register_memory_layer("Episodic", "Episodic memory layer")
1413            .unwrap();
1414
1415        let entities = engine.entity_count();
1416        assert!(entities >= 1, "should have at least 1 entity");
1417    }
1418
1419    #[test]
1420    fn register_operation_entity() {
1421        let engine =
1422            PolicyEngine::new(DEFAULT_SCHEMA, &[("default.cedar", DEFAULT_OPEN_POLICY)]).unwrap();
1423        engine
1424            .register_operation("Recall", "Recall operation")
1425            .unwrap();
1426
1427        let entities = engine.entity_count();
1428        assert!(entities >= 1, "should have at least 1 entity");
1429    }
1430
1431    #[test]
1432    fn register_tool_entity() {
1433        let engine =
1434            PolicyEngine::new(DEFAULT_SCHEMA, &[("default.cedar", DEFAULT_OPEN_POLICY)]).unwrap();
1435        engine
1436            .register_tool("remember_tool", "Memory toolkit: remember")
1437            .unwrap();
1438
1439        let entities = engine.entity_count();
1440        assert!(entities >= 1, "should have at least 1 entity");
1441    }
1442
1443    #[test]
1444    fn namespace_scoped_permit_policy() {
1445        let policy = r#"
1446            permit(
1447                principal == Hirn::Agent::"agent-a",
1448                action == Hirn::Action::"recall",
1449                resource == Hirn::Namespace::"team_x"
1450            );
1451        "#;
1452
1453        let engine = PolicyEngine::new(DEFAULT_SCHEMA, &[("ns.cedar", policy)]).unwrap();
1454        engine.register_realm("prod", "Production").unwrap();
1455        engine
1456            .register_namespace("team_x", "public", "prod")
1457            .unwrap();
1458        engine
1459            .register_namespace("team_y", "classified", "prod")
1460            .unwrap();
1461        engine
1462            .register_agent("agent-a", 50, "2025-01-01", &[])
1463            .unwrap();
1464        engine
1465            .register_agent("agent-b", 50, "2025-01-01", &[])
1466            .unwrap();
1467
1468        // agent-a can recall on team_x
1469        let d = engine.authorize(&AuthzRequest {
1470            agent_id: "agent-a".to_string(),
1471            action: Action::Recall,
1472            realm: "prod".to_string(),
1473            namespace: "team_x".to_string(),
1474        });
1475        assert!(d.allowed, "agent-a should access team_x: {d:?}");
1476
1477        // agent-a cannot recall on team_y (no matching permit)
1478        let d = engine.authorize(&AuthzRequest {
1479            agent_id: "agent-a".to_string(),
1480            action: Action::Recall,
1481            realm: "prod".to_string(),
1482            namespace: "team_y".to_string(),
1483        });
1484        assert!(!d.allowed, "agent-a should be denied team_y: {d:?}");
1485
1486        // agent-b cannot recall on team_x (no matching permit)
1487        let d = engine.authorize(&AuthzRequest {
1488            agent_id: "agent-b".to_string(),
1489            action: Action::Recall,
1490            realm: "prod".to_string(),
1491            namespace: "team_x".to_string(),
1492        });
1493        assert!(!d.allowed, "agent-b should be denied team_x: {d:?}");
1494    }
1495}