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