Skip to main content

chio_guards/
advisory.rs

1//! Advisory signal framework -- signed, non-blocking evidence observations.
2//!
3//! Advisory signals are distinct from deterministic guard verdicts. They
4//! record observations about a request without blocking it. This allows
5//! operators to see patterns and anomalies without impacting request flow.
6//!
7//! Key properties:
8//! - Advisory signals never deny requests on their own.
9//! - They produce `AdvisorySignal` entries that are included in evidence
10//!   alongside `GuardEvidence`.
11//! - Operators can promote advisory signals to deterministic guards via
12//!   `chio.yaml` configuration (see `PromotionPolicy`).
13//! - Advisory signals carry a severity level and structured metadata.
14
15use std::sync::Arc;
16
17use chio_kernel::{Guard, GuardContext, KernelError, Verdict};
18use serde::{Deserialize, Serialize};
19
20// ---------------------------------------------------------------------------
21// Advisory signal types
22// ---------------------------------------------------------------------------
23
24/// Severity level for an advisory signal.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum AdvisorySeverity {
28    /// Informational observation -- no action needed.
29    Info,
30    /// Low-severity warning -- worth monitoring.
31    Low,
32    /// Medium-severity warning -- may warrant investigation.
33    Medium,
34    /// High-severity warning -- likely needs attention.
35    High,
36    /// Critical observation -- strong signal of abuse or anomaly.
37    Critical,
38}
39
40/// A non-blocking advisory signal emitted by an advisory guard.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct AdvisorySignal {
43    /// Name of the advisory guard that produced this signal.
44    pub guard_name: String,
45    /// Human-readable description of the observation.
46    pub description: String,
47    /// Severity level.
48    pub severity: AdvisorySeverity,
49    /// Structured metadata about the observation.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub metadata: Option<serde_json::Value>,
52    /// Whether this signal has been promoted to a deterministic denial.
53    /// This is set by the promotion policy, not by the advisory guard itself.
54    #[serde(default)]
55    pub promoted: bool,
56}
57
58/// Classification of a guard's output: deterministic verdict or advisory signal.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60#[serde(tag = "type", rename_all = "snake_case")]
61pub enum GuardOutput {
62    /// A deterministic verdict (allow or deny).
63    Deterministic {
64        guard_name: String,
65        verdict: bool,
66        details: Option<String>,
67    },
68    /// A non-blocking advisory signal.
69    Advisory(AdvisorySignal),
70}
71
72// ---------------------------------------------------------------------------
73// Advisory guard trait
74// ---------------------------------------------------------------------------
75
76/// Trait for guards that produce advisory (non-blocking) signals.
77///
78/// Unlike the `Guard` trait which returns Allow/Deny verdicts, an
79/// `AdvisoryGuard` always allows the request but may emit observations.
80pub trait AdvisoryGuard: Send + Sync {
81    /// Human-readable guard name.
82    fn name(&self) -> &str;
83
84    /// Evaluate the request and return any advisory signals.
85    ///
86    /// The returned signals are informational only and do not affect the
87    /// request verdict unless a promotion policy is in effect.
88    fn evaluate(&self, ctx: &GuardContext) -> Result<Vec<AdvisorySignal>, KernelError>;
89}
90
91// ---------------------------------------------------------------------------
92// Promotion policy
93// ---------------------------------------------------------------------------
94
95/// Policy for promoting advisory signals to deterministic denials.
96///
97/// Operators configure this in `chio.yaml` to convert specific advisory
98/// signals into hard denials based on guard name and severity threshold.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct PromotionRule {
101    /// Guard name pattern to match (exact match).
102    pub guard_name: String,
103    /// Minimum severity to promote. Signals at or above this level
104    /// from the named guard become deterministic denials.
105    pub min_severity: AdvisorySeverity,
106}
107
108/// Collection of promotion rules loaded from configuration.
109#[derive(Debug, Clone, Default, Serialize, Deserialize)]
110pub struct PromotionPolicy {
111    /// Rules for promoting advisory signals to deterministic denials.
112    pub rules: Vec<PromotionRule>,
113}
114
115impl PromotionPolicy {
116    /// Create an empty policy (no promotions).
117    pub fn new() -> Self {
118        Self { rules: Vec::new() }
119    }
120
121    /// Add a promotion rule.
122    pub fn add_rule(&mut self, rule: PromotionRule) {
123        self.rules.push(rule);
124    }
125
126    /// Check whether a signal should be promoted to a denial.
127    pub fn should_promote(&self, signal: &AdvisorySignal) -> bool {
128        for rule in &self.rules {
129            if rule.guard_name == signal.guard_name
130                && severity_ord(signal.severity) >= severity_ord(rule.min_severity)
131            {
132                return true;
133            }
134        }
135        false
136    }
137}
138
139/// Convert severity to ordinal for comparison.
140fn severity_ord(s: AdvisorySeverity) -> u8 {
141    match s {
142        AdvisorySeverity::Info => 0,
143        AdvisorySeverity::Low => 1,
144        AdvisorySeverity::Medium => 2,
145        AdvisorySeverity::High => 3,
146        AdvisorySeverity::Critical => 4,
147    }
148}
149
150// ---------------------------------------------------------------------------
151// Advisory pipeline
152// ---------------------------------------------------------------------------
153
154/// Pipeline that evaluates advisory guards and optionally promotes signals.
155///
156/// This wraps multiple `AdvisoryGuard` implementations and a `PromotionPolicy`.
157/// It implements the `Guard` trait so it can be plugged into the standard
158/// guard pipeline. Without promotion rules, it always returns `Verdict::Allow`.
159pub struct AdvisoryPipeline {
160    guards: Vec<Box<dyn AdvisoryGuard>>,
161    policy: PromotionPolicy,
162    /// Collected signals from the last evaluation (for evidence export).
163    signals: std::sync::Mutex<Vec<AdvisorySignal>>,
164}
165
166impl AdvisoryPipeline {
167    /// Create a new pipeline with the given promotion policy.
168    pub fn new(policy: PromotionPolicy) -> Self {
169        Self {
170            guards: Vec::new(),
171            policy,
172            signals: std::sync::Mutex::new(Vec::new()),
173        }
174    }
175
176    /// Add an advisory guard to the pipeline.
177    pub fn add(&mut self, guard: Box<dyn AdvisoryGuard>) {
178        self.guards.push(guard);
179    }
180
181    /// Return the number of advisory guards in the pipeline.
182    pub fn len(&self) -> usize {
183        self.guards.len()
184    }
185
186    /// Return whether the pipeline has no guards.
187    pub fn is_empty(&self) -> bool {
188        self.guards.is_empty()
189    }
190
191    /// Return the signals collected during the last evaluation.
192    pub fn last_signals(&self) -> Result<Vec<AdvisorySignal>, KernelError> {
193        let signals = self
194            .signals
195            .lock()
196            .map_err(|_| KernelError::Internal("advisory pipeline lock poisoned".to_string()))?;
197        Ok(signals.clone())
198    }
199
200    /// Return the GuardOutput entries for the last evaluation.
201    pub fn last_outputs(&self) -> Result<Vec<GuardOutput>, KernelError> {
202        let signals = self.last_signals()?;
203        Ok(signals.into_iter().map(GuardOutput::Advisory).collect())
204    }
205}
206
207impl Guard for AdvisoryPipeline {
208    fn name(&self) -> &str {
209        "advisory-pipeline"
210    }
211
212    fn evaluate(&self, ctx: &GuardContext) -> Result<Verdict, KernelError> {
213        let mut collected = Vec::new();
214        let mut should_deny = false;
215
216        for guard in &self.guards {
217            let signals = guard.evaluate(ctx)?;
218            for mut signal in signals {
219                if self.policy.should_promote(&signal) {
220                    signal.promoted = true;
221                    should_deny = true;
222                }
223                collected.push(signal);
224            }
225        }
226
227        // Store collected signals for evidence export.
228        let mut stored = self
229            .signals
230            .lock()
231            .map_err(|_| KernelError::Internal("advisory pipeline lock poisoned".to_string()))?;
232        *stored = collected;
233
234        if should_deny {
235            Ok(Verdict::Deny)
236        } else {
237            Ok(Verdict::Allow)
238        }
239    }
240}
241
242// ---------------------------------------------------------------------------
243// Built-in advisory guards
244// ---------------------------------------------------------------------------
245
246/// Advisory guard that flags unusual tool invocation patterns.
247///
248/// Emits advisory signals when:
249/// - A tool is invoked more than a threshold number of times in a session
250/// - Delegation depth exceeds a threshold
251pub struct AnomalyAdvisoryGuard {
252    journal: Arc<chio_http_session::SessionJournal>,
253    /// Threshold for per-tool invocation count advisory.
254    invocation_threshold: u64,
255    /// Threshold for delegation depth advisory.
256    depth_threshold: u32,
257}
258
259impl AnomalyAdvisoryGuard {
260    /// Create a new anomaly advisory guard.
261    pub fn new(
262        journal: Arc<chio_http_session::SessionJournal>,
263        invocation_threshold: u64,
264        depth_threshold: u32,
265    ) -> Self {
266        Self {
267            journal,
268            invocation_threshold,
269            depth_threshold,
270        }
271    }
272}
273
274impl AdvisoryGuard for AnomalyAdvisoryGuard {
275    fn name(&self) -> &str {
276        "anomaly-advisory"
277    }
278
279    fn evaluate(&self, ctx: &GuardContext) -> Result<Vec<AdvisorySignal>, KernelError> {
280        let mut signals = Vec::new();
281
282        let tool_counts = self
283            .journal
284            .tool_counts()
285            .map_err(|e| KernelError::Internal(format!("anomaly advisory journal error: {e}")))?;
286
287        // Check if current tool has been invoked excessively.
288        if let Some(count) = tool_counts.get(&ctx.request.tool_name) {
289            if *count >= self.invocation_threshold {
290                signals.push(AdvisorySignal {
291                    guard_name: "anomaly-advisory".to_string(),
292                    description: format!(
293                        "tool '{}' invoked {} times (threshold: {})",
294                        ctx.request.tool_name, count, self.invocation_threshold
295                    ),
296                    severity: if *count >= self.invocation_threshold * 2 {
297                        AdvisorySeverity::High
298                    } else {
299                        AdvisorySeverity::Medium
300                    },
301                    metadata: Some(serde_json::json!({
302                        "tool_name": ctx.request.tool_name,
303                        "count": count,
304                        "threshold": self.invocation_threshold,
305                    })),
306                    promoted: false,
307                });
308            }
309        }
310
311        // Check delegation depth.
312        let data_flow = self
313            .journal
314            .data_flow()
315            .map_err(|e| KernelError::Internal(format!("anomaly advisory journal error: {e}")))?;
316
317        if data_flow.max_delegation_depth >= self.depth_threshold {
318            signals.push(AdvisorySignal {
319                guard_name: "anomaly-advisory".to_string(),
320                description: format!(
321                    "delegation depth {} exceeds threshold {}",
322                    data_flow.max_delegation_depth, self.depth_threshold
323                ),
324                severity: AdvisorySeverity::High,
325                metadata: Some(serde_json::json!({
326                    "max_delegation_depth": data_flow.max_delegation_depth,
327                    "threshold": self.depth_threshold,
328                })),
329                promoted: false,
330            });
331        }
332
333        Ok(signals)
334    }
335}
336
337/// Advisory guard that flags high data transfer volumes.
338pub struct DataTransferAdvisoryGuard {
339    journal: Arc<chio_http_session::SessionJournal>,
340    /// Bytes threshold for advisory signal.
341    bytes_threshold: u64,
342}
343
344impl DataTransferAdvisoryGuard {
345    /// Create a new data transfer advisory guard.
346    pub fn new(journal: Arc<chio_http_session::SessionJournal>, bytes_threshold: u64) -> Self {
347        Self {
348            journal,
349            bytes_threshold,
350        }
351    }
352}
353
354impl AdvisoryGuard for DataTransferAdvisoryGuard {
355    fn name(&self) -> &str {
356        "data-transfer-advisory"
357    }
358
359    fn evaluate(&self, _ctx: &GuardContext) -> Result<Vec<AdvisorySignal>, KernelError> {
360        let flow = self.journal.data_flow().map_err(|e| {
361            KernelError::Internal(format!("data-transfer advisory journal error: {e}"))
362        })?;
363
364        let total = flow
365            .total_bytes_read
366            .saturating_add(flow.total_bytes_written);
367
368        if total >= self.bytes_threshold {
369            let severity = if total >= self.bytes_threshold.saturating_mul(3) {
370                AdvisorySeverity::Critical
371            } else if total >= self.bytes_threshold.saturating_mul(2) {
372                AdvisorySeverity::High
373            } else {
374                AdvisorySeverity::Medium
375            };
376
377            Ok(vec![AdvisorySignal {
378                guard_name: "data-transfer-advisory".to_string(),
379                description: format!(
380                    "cumulative data transfer {} bytes exceeds threshold {} bytes",
381                    total, self.bytes_threshold
382                ),
383                severity,
384                metadata: Some(serde_json::json!({
385                    "total_bytes": total,
386                    "bytes_read": flow.total_bytes_read,
387                    "bytes_written": flow.total_bytes_written,
388                    "threshold": self.bytes_threshold,
389                })),
390                promoted: false,
391            }])
392        } else {
393            Ok(vec![])
394        }
395    }
396}
397
398// ---------------------------------------------------------------------------
399// Tests
400// ---------------------------------------------------------------------------
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405    use chio_http_session::{RecordParams, SessionJournal};
406
407    fn make_journal(session_id: &str) -> Arc<SessionJournal> {
408        Arc::new(SessionJournal::new(session_id.to_string()))
409    }
410
411    fn record(journal: &SessionJournal, tool: &str, bytes_read: u64, depth: u32) {
412        journal
413            .record(RecordParams {
414                tool_name: tool.to_string(),
415                server_id: "srv".to_string(),
416                agent_id: "agent".to_string(),
417                bytes_read,
418                bytes_written: 0,
419                delegation_depth: depth,
420                allowed: true,
421            })
422            .expect("record");
423    }
424
425    fn make_ctx() -> (
426        chio_kernel::ToolCallRequest,
427        chio_core::capability::ChioScope,
428        String,
429        String,
430    ) {
431        let kp = chio_core::crypto::Keypair::generate();
432        let scope = chio_core::capability::ChioScope::default();
433        let agent_id = kp.public_key().to_hex();
434        let server_id = "srv-test".to_string();
435
436        let cap_body = chio_core::capability::CapabilityTokenBody {
437            id: "cap-test".to_string(),
438            issuer: kp.public_key(),
439            subject: kp.public_key(),
440            scope: scope.clone(),
441            issued_at: 0,
442            expires_at: u64::MAX,
443            delegation_chain: vec![],
444        };
445        let cap = chio_core::capability::CapabilityToken::sign(cap_body, &kp).expect("sign cap");
446
447        let request = chio_kernel::ToolCallRequest {
448            request_id: "req-test".to_string(),
449            capability: cap,
450            tool_name: "read_file".to_string(),
451            server_id: server_id.clone(),
452            agent_id: agent_id.clone(),
453            arguments: serde_json::json!({"path": "/app/src/main.rs"}),
454            dpop_proof: None,
455            governed_intent: None,
456            approval_token: None,
457            model_metadata: None,
458            federated_origin_kernel_id: None,
459        };
460
461        (request, scope, agent_id, server_id)
462    }
463
464    fn guard_ctx<'a>(
465        request: &'a chio_kernel::ToolCallRequest,
466        scope: &'a chio_core::capability::ChioScope,
467        agent_id: &'a String,
468        server_id: &'a String,
469    ) -> chio_kernel::GuardContext<'a> {
470        chio_kernel::GuardContext {
471            request,
472            scope,
473            agent_id,
474            server_id,
475            session_filesystem_roots: None,
476            matched_grant_index: None,
477        }
478    }
479
480    // -- AdvisorySignal tests --
481
482    #[test]
483    fn advisory_signal_serde_roundtrip() {
484        let signal = AdvisorySignal {
485            guard_name: "test-guard".to_string(),
486            description: "test observation".to_string(),
487            severity: AdvisorySeverity::Medium,
488            metadata: Some(serde_json::json!({"key": "value"})),
489            promoted: false,
490        };
491
492        let json = serde_json::to_string(&signal).expect("serialize");
493        let restored: AdvisorySignal = serde_json::from_str(&json).expect("deserialize");
494        assert_eq!(restored.guard_name, "test-guard");
495        assert_eq!(restored.severity, AdvisorySeverity::Medium);
496        assert!(!restored.promoted);
497    }
498
499    #[test]
500    fn guard_output_distinguishes_types() {
501        let det = GuardOutput::Deterministic {
502            guard_name: "forbidden-path".to_string(),
503            verdict: false,
504            details: Some("blocked".to_string()),
505        };
506        let adv = GuardOutput::Advisory(AdvisorySignal {
507            guard_name: "anomaly".to_string(),
508            description: "unusual pattern".to_string(),
509            severity: AdvisorySeverity::Low,
510            metadata: None,
511            promoted: false,
512        });
513
514        let det_json = serde_json::to_string(&det).expect("serialize det");
515        let adv_json = serde_json::to_string(&adv).expect("serialize adv");
516
517        assert!(det_json.contains("\"type\":\"deterministic\""));
518        assert!(adv_json.contains("\"type\":\"advisory\""));
519    }
520
521    // -- PromotionPolicy tests --
522
523    #[test]
524    fn promotion_policy_empty_never_promotes() {
525        let policy = PromotionPolicy::new();
526        let signal = AdvisorySignal {
527            guard_name: "test".to_string(),
528            description: "test".to_string(),
529            severity: AdvisorySeverity::Critical,
530            metadata: None,
531            promoted: false,
532        };
533        assert!(!policy.should_promote(&signal));
534    }
535
536    #[test]
537    fn promotion_policy_promotes_matching_signal() {
538        let mut policy = PromotionPolicy::new();
539        policy.add_rule(PromotionRule {
540            guard_name: "anomaly-advisory".to_string(),
541            min_severity: AdvisorySeverity::High,
542        });
543
544        let high_signal = AdvisorySignal {
545            guard_name: "anomaly-advisory".to_string(),
546            description: "test".to_string(),
547            severity: AdvisorySeverity::High,
548            metadata: None,
549            promoted: false,
550        };
551        assert!(policy.should_promote(&high_signal));
552
553        let critical_signal = AdvisorySignal {
554            guard_name: "anomaly-advisory".to_string(),
555            description: "test".to_string(),
556            severity: AdvisorySeverity::Critical,
557            metadata: None,
558            promoted: false,
559        };
560        assert!(policy.should_promote(&critical_signal));
561    }
562
563    #[test]
564    fn promotion_policy_does_not_promote_below_threshold() {
565        let mut policy = PromotionPolicy::new();
566        policy.add_rule(PromotionRule {
567            guard_name: "anomaly-advisory".to_string(),
568            min_severity: AdvisorySeverity::High,
569        });
570
571        let low_signal = AdvisorySignal {
572            guard_name: "anomaly-advisory".to_string(),
573            description: "test".to_string(),
574            severity: AdvisorySeverity::Medium,
575            metadata: None,
576            promoted: false,
577        };
578        assert!(!policy.should_promote(&low_signal));
579    }
580
581    #[test]
582    fn promotion_policy_does_not_promote_wrong_guard() {
583        let mut policy = PromotionPolicy::new();
584        policy.add_rule(PromotionRule {
585            guard_name: "anomaly-advisory".to_string(),
586            min_severity: AdvisorySeverity::Low,
587        });
588
589        let signal = AdvisorySignal {
590            guard_name: "other-guard".to_string(),
591            description: "test".to_string(),
592            severity: AdvisorySeverity::Critical,
593            metadata: None,
594            promoted: false,
595        };
596        assert!(!policy.should_promote(&signal));
597    }
598
599    // -- AdvisoryPipeline tests --
600
601    struct NoOpAdvisory;
602    impl AdvisoryGuard for NoOpAdvisory {
603        fn name(&self) -> &str {
604            "no-op"
605        }
606        fn evaluate(&self, _ctx: &GuardContext) -> Result<Vec<AdvisorySignal>, KernelError> {
607            Ok(vec![])
608        }
609    }
610
611    struct AlwaysSignal {
612        guard_name: String,
613        severity: AdvisorySeverity,
614    }
615    impl AdvisoryGuard for AlwaysSignal {
616        fn name(&self) -> &str {
617            &self.guard_name
618        }
619        fn evaluate(&self, _ctx: &GuardContext) -> Result<Vec<AdvisorySignal>, KernelError> {
620            Ok(vec![AdvisorySignal {
621                guard_name: self.guard_name.clone(),
622                description: "always signals".to_string(),
623                severity: self.severity,
624                metadata: None,
625                promoted: false,
626            }])
627        }
628    }
629
630    #[test]
631    fn advisory_pipeline_allows_without_promotion() {
632        let mut pipeline = AdvisoryPipeline::new(PromotionPolicy::new());
633        pipeline.add(Box::new(AlwaysSignal {
634            guard_name: "test-signal".to_string(),
635            severity: AdvisorySeverity::High,
636        }));
637
638        let (request, scope, agent_id, server_id) = make_ctx();
639        let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
640        let result = pipeline.evaluate(&ctx).expect("ok");
641        assert_eq!(result, Verdict::Allow);
642
643        let signals = pipeline.last_signals().expect("signals");
644        assert_eq!(signals.len(), 1);
645        assert!(!signals[0].promoted);
646    }
647
648    #[test]
649    fn advisory_pipeline_denies_with_promotion() {
650        let mut policy = PromotionPolicy::new();
651        policy.add_rule(PromotionRule {
652            guard_name: "test-signal".to_string(),
653            min_severity: AdvisorySeverity::High,
654        });
655
656        let mut pipeline = AdvisoryPipeline::new(policy);
657        pipeline.add(Box::new(AlwaysSignal {
658            guard_name: "test-signal".to_string(),
659            severity: AdvisorySeverity::High,
660        }));
661
662        let (request, scope, agent_id, server_id) = make_ctx();
663        let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
664        let result = pipeline.evaluate(&ctx).expect("ok");
665        assert_eq!(result, Verdict::Deny);
666
667        let signals = pipeline.last_signals().expect("signals");
668        assert_eq!(signals.len(), 1);
669        assert!(signals[0].promoted);
670    }
671
672    #[test]
673    fn advisory_pipeline_no_guards_allows() {
674        let pipeline = AdvisoryPipeline::new(PromotionPolicy::new());
675
676        let (request, scope, agent_id, server_id) = make_ctx();
677        let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
678        assert_eq!(pipeline.evaluate(&ctx).expect("ok"), Verdict::Allow);
679    }
680
681    #[test]
682    fn advisory_pipeline_collects_multiple_signals() {
683        let mut pipeline = AdvisoryPipeline::new(PromotionPolicy::new());
684        pipeline.add(Box::new(AlwaysSignal {
685            guard_name: "signal-a".to_string(),
686            severity: AdvisorySeverity::Low,
687        }));
688        pipeline.add(Box::new(AlwaysSignal {
689            guard_name: "signal-b".to_string(),
690            severity: AdvisorySeverity::Medium,
691        }));
692
693        let (request, scope, agent_id, server_id) = make_ctx();
694        let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
695        pipeline.evaluate(&ctx).expect("ok");
696
697        let signals = pipeline.last_signals().expect("signals");
698        assert_eq!(signals.len(), 2);
699        assert_eq!(signals[0].guard_name, "signal-a");
700        assert_eq!(signals[1].guard_name, "signal-b");
701    }
702
703    #[test]
704    fn advisory_pipeline_guard_output_types() {
705        let mut pipeline = AdvisoryPipeline::new(PromotionPolicy::new());
706        pipeline.add(Box::new(AlwaysSignal {
707            guard_name: "test".to_string(),
708            severity: AdvisorySeverity::Info,
709        }));
710
711        let (request, scope, agent_id, server_id) = make_ctx();
712        let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
713        pipeline.evaluate(&ctx).expect("ok");
714
715        let outputs = pipeline.last_outputs().expect("outputs");
716        assert_eq!(outputs.len(), 1);
717        assert!(matches!(outputs[0], GuardOutput::Advisory(_)));
718    }
719
720    // -- AnomalyAdvisoryGuard tests --
721
722    #[test]
723    fn anomaly_advisory_no_signal_below_threshold() {
724        let journal = make_journal("sess-anomaly-1");
725        for _ in 0..4 {
726            record(&journal, "read_file", 100, 0);
727        }
728
729        let guard = AnomalyAdvisoryGuard::new(journal, 10, 5);
730        let (request, scope, agent_id, server_id) = make_ctx();
731        let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
732        let signals = guard.evaluate(&ctx).expect("ok");
733        assert!(signals.is_empty());
734    }
735
736    #[test]
737    fn anomaly_advisory_signals_excessive_invocations() {
738        let journal = make_journal("sess-anomaly-2");
739        for _ in 0..10 {
740            record(&journal, "read_file", 100, 0);
741        }
742
743        let guard = AnomalyAdvisoryGuard::new(journal, 5, 10);
744        let (request, scope, agent_id, server_id) = make_ctx();
745        let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
746        let signals = guard.evaluate(&ctx).expect("ok");
747        assert!(!signals.is_empty());
748        assert!(signals.iter().any(|s| s.description.contains("read_file")));
749    }
750
751    #[test]
752    fn anomaly_advisory_signals_deep_delegation() {
753        let journal = make_journal("sess-anomaly-3");
754        record(&journal, "read_file", 100, 8);
755
756        let guard = AnomalyAdvisoryGuard::new(journal, 100, 5);
757        let (request, scope, agent_id, server_id) = make_ctx();
758        let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
759        let signals = guard.evaluate(&ctx).expect("ok");
760        assert!(!signals.is_empty());
761        assert!(signals
762            .iter()
763            .any(|s| s.description.contains("delegation depth")));
764    }
765
766    // -- DataTransferAdvisoryGuard tests --
767
768    #[test]
769    fn data_transfer_advisory_no_signal_below_threshold() {
770        let journal = make_journal("sess-transfer-1");
771        record(&journal, "read_file", 100, 0);
772
773        let guard = DataTransferAdvisoryGuard::new(journal, 10_000);
774        let (request, scope, agent_id, server_id) = make_ctx();
775        let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
776        let signals = guard.evaluate(&ctx).expect("ok");
777        assert!(signals.is_empty());
778    }
779
780    #[test]
781    fn data_transfer_advisory_signals_above_threshold() {
782        let journal = make_journal("sess-transfer-2");
783        for _ in 0..20 {
784            record(&journal, "read_file", 1000, 0);
785        }
786
787        let guard = DataTransferAdvisoryGuard::new(journal, 10_000);
788        let (request, scope, agent_id, server_id) = make_ctx();
789        let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
790        let signals = guard.evaluate(&ctx).expect("ok");
791        assert_eq!(signals.len(), 1);
792        assert!(signals[0].description.contains("data transfer"));
793    }
794
795    #[test]
796    fn data_transfer_advisory_escalating_severity() {
797        let journal = make_journal("sess-transfer-3");
798        // 30x threshold => Critical
799        for _ in 0..30 {
800            record(&journal, "read_file", 1000, 0);
801        }
802
803        let guard = DataTransferAdvisoryGuard::new(journal, 10_000);
804        let (request, scope, agent_id, server_id) = make_ctx();
805        let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
806        let signals = guard.evaluate(&ctx).expect("ok");
807        assert_eq!(signals.len(), 1);
808        assert_eq!(signals[0].severity, AdvisorySeverity::Critical);
809    }
810
811    // -- Integration: advisory pipeline with promotion --
812
813    #[test]
814    fn promoted_anomaly_denies_request() {
815        let journal = make_journal("sess-promote");
816        for _ in 0..20 {
817            record(&journal, "read_file", 100, 0);
818        }
819
820        let mut policy = PromotionPolicy::new();
821        policy.add_rule(PromotionRule {
822            guard_name: "anomaly-advisory".to_string(),
823            min_severity: AdvisorySeverity::Medium,
824        });
825
826        let mut pipeline = AdvisoryPipeline::new(policy);
827        pipeline.add(Box::new(AnomalyAdvisoryGuard::new(journal, 5, 10)));
828
829        let (request, scope, agent_id, server_id) = make_ctx();
830        let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
831        let result = pipeline.evaluate(&ctx).expect("ok");
832        assert_eq!(result, Verdict::Deny, "promoted advisory should deny");
833
834        let signals = pipeline.last_signals().expect("signals");
835        assert!(signals.iter().any(|s| s.promoted));
836    }
837
838    #[test]
839    fn len_and_is_empty() {
840        let mut pipeline = AdvisoryPipeline::new(PromotionPolicy::new());
841        assert!(pipeline.is_empty());
842        assert_eq!(pipeline.len(), 0);
843        pipeline.add(Box::new(NoOpAdvisory));
844        assert!(!pipeline.is_empty());
845        assert_eq!(pipeline.len(), 1);
846    }
847
848    #[test]
849    fn promotion_policy_serde_roundtrip() {
850        let mut policy = PromotionPolicy::new();
851        policy.add_rule(PromotionRule {
852            guard_name: "anomaly-advisory".to_string(),
853            min_severity: AdvisorySeverity::High,
854        });
855
856        let json = serde_json::to_string(&policy).expect("serialize");
857        let restored: PromotionPolicy = serde_json::from_str(&json).expect("deserialize");
858        assert_eq!(restored.rules.len(), 1);
859        assert_eq!(restored.rules[0].guard_name, "anomaly-advisory");
860        assert_eq!(restored.rules[0].min_severity, AdvisorySeverity::High);
861    }
862}