1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum AdvisorySeverity {
31 Info,
33 Low,
35 Medium,
37 High,
39 Critical,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct AdvisorySignal {
46 pub guard_name: String,
48 pub description: String,
50 pub severity: AdvisorySeverity,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub metadata: Option<serde_json::Value>,
55 #[serde(default)]
58 pub promoted: bool,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
63#[serde(tag = "type", rename_all = "snake_case")]
64pub enum GuardOutput {
65 Deterministic {
67 guard_name: String,
68 verdict: bool,
69 details: Option<String>,
70 },
71 Advisory(AdvisorySignal),
73}
74
75pub trait AdvisoryGuard: Send + Sync {
84 fn name(&self) -> &str;
86
87 fn evaluate(&self, ctx: &GuardContext) -> Result<Vec<AdvisorySignal>, KernelError>;
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct PromotionRule {
104 pub guard_name: String,
106 pub min_severity: AdvisorySeverity,
109}
110
111#[derive(Debug, Clone, Default, Serialize, Deserialize)]
113pub struct PromotionPolicy {
114 pub rules: Vec<PromotionRule>,
116}
117
118impl PromotionPolicy {
119 pub fn new() -> Self {
121 Self { rules: Vec::new() }
122 }
123
124 pub fn add_rule(&mut self, rule: PromotionRule) {
126 self.rules.push(rule);
127 }
128
129 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
142fn 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
180pub struct AdvisoryPipeline {
190 guards: Vec<Box<dyn AdvisoryGuard>>,
191 policy: PromotionPolicy,
192 signals: std::sync::Mutex<Vec<AdvisorySignal>>,
194}
195
196impl AdvisoryPipeline {
197 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 pub fn add(&mut self, guard: Box<dyn AdvisoryGuard>) {
208 self.guards.push(guard);
209 }
210
211 pub fn len(&self) -> usize {
213 self.guards.len()
214 }
215
216 pub fn is_empty(&self) -> bool {
218 self.guards.is_empty()
219 }
220
221 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 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 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
274pub struct AnomalyAdvisoryGuard {
284 journal: Arc<chio_http_session::SessionJournal>,
285 invocation_threshold: u64,
287 depth_threshold: u32,
289}
290
291impl AnomalyAdvisoryGuard {
292 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 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 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
367pub struct DataTransferAdvisoryGuard {
369 journal: Arc<chio_http_session::SessionJournal>,
370 bytes_threshold: u64,
372}
373
374impl DataTransferAdvisoryGuard {
375 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#[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 #[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 #[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 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 #[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 #[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 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 #[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}