1use regex::RegexSet;
7use serde::{Deserialize, Serialize};
8
9use crate::classifier::OperationType;
10
11fn default_read_intent_keywords() -> Vec<String> {
19 vec![
20 "read".into(),
21 "analyze".into(),
22 "summarize".into(),
23 "review".into(),
24 "inspect".into(),
25 "view".into(),
26 "check".into(),
27 "list".into(),
28 "search".into(),
29 "query".into(),
30 "describe".into(),
31 "explain".into(),
32 ]
33}
34
35fn default_write_intent_keywords() -> Vec<String> {
40 vec![
41 "write".into(),
42 "create".into(),
43 "update".into(),
44 "modify".into(),
45 "edit".into(),
46 "deploy".into(),
47 "build".into(),
48 "generate".into(),
49 "publish".into(),
50 "upload".into(),
51 ]
52}
53
54fn default_admin_intent_keywords() -> Vec<String> {
59 vec![
60 "admin".into(),
61 "manage".into(),
62 "configure".into(),
63 "setup".into(),
64 "install".into(),
65 "maintain".into(),
66 "operate".into(),
67 "provision".into(),
68 ]
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum IntentTier {
78 Unknown,
80 Read,
82 Write,
84 Admin,
86}
87
88fn default_suspicious_arg_patterns() -> Vec<String> {
98 vec![
99 "rm -rf".into(),
100 "rm -f".into(),
101 "mkfs".into(),
102 "dd if=".into(),
103 "chmod 777".into(),
104 "drop table".into(),
105 "drop database".into(),
106 "delete from".into(),
107 "truncate table".into(),
108 "; --".into(),
109 "' or '1'='1".into(),
110 "union select".into(),
111 "../../../".into(),
112 "..\\..\\".into(),
113 ]
114}
115
116const SUSPICIOUS_ARG_KEY_FRAGMENTS: &[&str] = &[
119 "exec", "shell", "command", "query", "sql", "eval", "script", "code", "run", "system",
120];
121
122const MAX_READ_ARG_STRING_LEN: usize = 1024;
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct AnomalyConfig {
129 #[serde(default)]
132 pub escalate_to_deny: bool,
133
134 #[serde(default = "default_read_intent_keywords")]
138 pub read_intent_keywords: Vec<String>,
139
140 #[serde(default = "default_write_intent_keywords")]
144 pub write_intent_keywords: Vec<String>,
145
146 #[serde(default = "default_admin_intent_keywords")]
149 pub admin_intent_keywords: Vec<String>,
150
151 #[serde(default = "default_suspicious_arg_patterns")]
154 pub suspicious_arg_patterns: Vec<String>,
155}
156
157impl Default for AnomalyConfig {
158 fn default() -> Self {
159 Self {
160 escalate_to_deny: false,
161 read_intent_keywords: default_read_intent_keywords(),
162 write_intent_keywords: default_write_intent_keywords(),
163 admin_intent_keywords: default_admin_intent_keywords(),
164 suspicious_arg_patterns: default_suspicious_arg_patterns(),
165 }
166 }
167}
168
169#[derive(Debug, Clone, PartialEq, Eq)]
171pub enum AnomalyResponse {
172 Normal,
174 Flagged {
176 reason: String,
178 },
179 Denied {
181 reason: String,
183 },
184}
185
186pub struct AnomalyDetector {
188 config: AnomalyConfig,
189 read_intent_regex: RegexSet,
192 write_intent_regex: RegexSet,
193 admin_intent_regex: RegexSet,
194}
195
196fn build_regex_set(keywords: &[String], label: &str) -> RegexSet {
197 let patterns: Vec<String> = keywords
198 .iter()
199 .map(|p| format!(r"(?i)\b{}\b", regex::escape(p)))
200 .collect();
201 RegexSet::new(&patterns).unwrap_or_else(|e| panic!("{label} must be valid regex atoms: {e}"))
202}
203
204impl AnomalyDetector {
205 pub fn new(config: AnomalyConfig) -> Self {
208 let read_intent_regex =
209 build_regex_set(&config.read_intent_keywords, "read_intent_keywords");
210 let write_intent_regex =
211 build_regex_set(&config.write_intent_keywords, "write_intent_keywords");
212 let admin_intent_regex =
213 build_regex_set(&config.admin_intent_keywords, "admin_intent_keywords");
214 Self {
215 config,
216 read_intent_regex,
217 write_intent_regex,
218 admin_intent_regex,
219 }
220 }
221
222 pub fn classify_intent(&self, declared_intent: &str) -> IntentTier {
225 if self.admin_intent_regex.is_match(declared_intent) {
226 IntentTier::Admin
227 } else if self.write_intent_regex.is_match(declared_intent) {
228 IntentTier::Write
229 } else if self.read_intent_regex.is_match(declared_intent) {
230 IntentTier::Read
231 } else {
232 IntentTier::Unknown
233 }
234 }
235
236 pub fn detect(
244 &self,
245 declared_intent: &str,
246 operation_type: OperationType,
247 tool_name: &str,
248 ) -> AnomalyResponse {
249 self.detect_with_args(declared_intent, operation_type, tool_name, None)
250 }
251
252 pub fn detect_with_args(
254 &self,
255 declared_intent: &str,
256 operation_type: OperationType,
257 tool_name: &str,
258 arguments: Option<&serde_json::Value>,
259 ) -> AnomalyResponse {
260 let tier = self.classify_intent(declared_intent);
261
262 let is_anomalous = match tier {
263 IntentTier::Unknown => true,
268 IntentTier::Admin => operation_type == OperationType::Delete,
271 IntentTier::Write => operation_type == OperationType::Admin,
272 IntentTier::Read => !matches!(operation_type, OperationType::Read),
273 };
274
275 if !is_anomalous {
276 if matches!(tier, IntentTier::Read | IntentTier::Unknown)
280 && let Some(args) = arguments
281 {
282 let text = args.to_string().to_lowercase();
284 for pattern in &self.config.suspicious_arg_patterns {
285 if text.contains(pattern.as_str()) {
286 let reason = format!(
287 "suspicious argument content in tool '{}': pattern '{}' detected",
288 tool_name, pattern
289 );
290 return if self.config.escalate_to_deny {
291 AnomalyResponse::Denied { reason }
292 } else {
293 AnomalyResponse::Flagged { reason }
294 };
295 }
296 }
297
298 if let Some(reason) = check_structural_anomalies(args, tool_name) {
300 return if self.config.escalate_to_deny {
301 AnomalyResponse::Denied { reason }
302 } else {
303 AnomalyResponse::Flagged { reason }
304 };
305 }
306 }
307 return AnomalyResponse::Normal;
308 }
309
310 let tier_label = match tier {
311 IntentTier::Unknown => "unknown (unclassified)",
312 IntentTier::Read => "read-only",
313 IntentTier::Write => "write",
314 IntentTier::Admin => "admin",
315 };
316
317 let reason = format!(
318 "session intent '{}' classified as {}, but tool '{}' classified as {:?}",
319 declared_intent, tier_label, tool_name, operation_type
320 );
321
322 tracing::warn!(
323 intent = %declared_intent,
324 tool = %tool_name,
325 operation = ?operation_type,
326 intent_tier = ?tier,
327 "behavioral anomaly detected"
328 );
329
330 if self.config.escalate_to_deny {
331 AnomalyResponse::Denied { reason }
332 } else {
333 AnomalyResponse::Flagged { reason }
334 }
335 }
336}
337
338fn check_structural_anomalies(args: &serde_json::Value, tool_name: &str) -> Option<String> {
346 let obj = args.as_object()?;
347
348 for (key, value) in obj {
349 if value.is_object() || value.is_array() {
351 return Some(format!(
352 "structural anomaly in tool '{}': argument '{}' contains a nested {} in a read session",
353 tool_name,
354 key,
355 if value.is_object() { "object" } else { "array" },
356 ));
357 }
358
359 let key_lower = key.to_lowercase();
361 for fragment in SUSPICIOUS_ARG_KEY_FRAGMENTS {
362 if key_lower.contains(fragment) {
363 return Some(format!(
364 "structural anomaly in tool '{}': argument key '{}' contains suspicious fragment '{}'",
365 tool_name, key, fragment,
366 ));
367 }
368 }
369
370 if let Some(s) = value.as_str()
372 && s.len() > MAX_READ_ARG_STRING_LEN
373 {
374 return Some(format!(
375 "structural anomaly in tool '{}': argument '{}' has a string value of {} bytes (max {})",
376 tool_name,
377 key,
378 s.len(),
379 MAX_READ_ARG_STRING_LEN,
380 ));
381 }
382 }
383
384 None
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390
391 #[test]
392 fn normal_read_sequence_no_anomaly() {
393 let detector = AnomalyDetector::new(AnomalyConfig::default());
394
395 let result = detector.detect(
396 "read and analyze the log files",
397 OperationType::Read,
398 "read_file",
399 );
400 assert_eq!(result, AnomalyResponse::Normal);
401
402 let result = detector.detect("summarize the report", OperationType::Read, "get_document");
403 assert_eq!(result, AnomalyResponse::Normal);
404 }
405
406 #[test]
407 fn write_in_read_only_session_flagged() {
408 let detector = AnomalyDetector::new(AnomalyConfig {
409 escalate_to_deny: false,
410 ..Default::default()
411 });
412
413 let result = detector.detect(
414 "read the configuration files",
415 OperationType::Write,
416 "write_file",
417 );
418 assert!(matches!(result, AnomalyResponse::Flagged { .. }));
419
420 let result = detector.detect(
422 "analyze the database",
423 OperationType::Delete,
424 "delete_record",
425 );
426 assert!(matches!(result, AnomalyResponse::Flagged { .. }));
427 }
428
429 #[test]
430 fn anomaly_escalation_to_deny() {
431 let detector = AnomalyDetector::new(AnomalyConfig {
432 escalate_to_deny: true,
433 ..Default::default()
434 });
435
436 let result = detector.detect("review the source code", OperationType::Write, "write_file");
437 assert!(matches!(result, AnomalyResponse::Denied { .. }));
438
439 if let AnomalyResponse::Denied { reason } = result {
440 assert!(reason.contains("review the source code"));
441 assert!(reason.contains("write_file"));
442 }
443 }
444
445 #[test]
446 fn admin_in_read_session_detected() {
447 let detector = AnomalyDetector::new(AnomalyConfig {
448 escalate_to_deny: false,
449 ..Default::default()
450 });
451
452 let result = detector.detect(
453 "check the system status",
454 OperationType::Admin,
455 "configure_settings",
456 );
457 assert!(matches!(result, AnomalyResponse::Flagged { .. }));
458 }
459
460 #[test]
463 fn classify_intent_tiers() {
464 let detector = AnomalyDetector::new(AnomalyConfig::default());
465
466 assert_eq!(detector.classify_intent("read the logs"), IntentTier::Read);
467 assert_eq!(
468 detector.classify_intent("analyze reports"),
469 IntentTier::Read
470 );
471 assert_eq!(
472 detector.classify_intent("create new user"),
473 IntentTier::Write
474 );
475 assert_eq!(
476 detector.classify_intent("deploy the app"),
477 IntentTier::Write
478 );
479 assert_eq!(
480 detector.classify_intent("manage the servers"),
481 IntentTier::Admin
482 );
483 assert_eq!(
484 detector.classify_intent("configure settings"),
485 IntentTier::Admin
486 );
487 assert_eq!(
488 detector.classify_intent("do something"),
489 IntentTier::Unknown
490 );
491 }
492
493 #[test]
494 fn admin_intent_highest_precedence() {
495 let detector = AnomalyDetector::new(AnomalyConfig::default());
496
497 assert_eq!(
499 detector.classify_intent("manage and read the system"),
500 IntentTier::Admin
501 );
502 }
503
504 #[test]
505 fn write_intent_beats_read() {
506 let detector = AnomalyDetector::new(AnomalyConfig::default());
507
508 assert_eq!(
510 detector.classify_intent("read files and create backups"),
511 IntentTier::Write
512 );
513 }
514
515 #[test]
516 fn write_intent_allows_writes_but_flags_admin() {
517 let detector = AnomalyDetector::new(AnomalyConfig {
518 escalate_to_deny: false,
519 ..Default::default()
520 });
521
522 let result = detector.detect("create new documents", OperationType::Read, "list_files");
524 assert_eq!(result, AnomalyResponse::Normal);
525
526 let result = detector.detect("create new documents", OperationType::Write, "write_file");
527 assert_eq!(result, AnomalyResponse::Normal);
528
529 let result = detector.detect("create new documents", OperationType::Delete, "delete_file");
530 assert_eq!(result, AnomalyResponse::Normal);
531
532 let result = detector.detect(
534 "create new documents",
535 OperationType::Admin,
536 "configure_settings",
537 );
538 assert!(matches!(result, AnomalyResponse::Flagged { .. }));
539 }
540
541 #[test]
542 fn admin_intent_allows_non_delete_operations() {
543 let detector = AnomalyDetector::new(AnomalyConfig {
544 escalate_to_deny: true,
545 ..Default::default()
546 });
547
548 for op in [
549 OperationType::Read,
550 OperationType::Write,
551 OperationType::Admin,
552 ] {
553 let result = detector.detect("manage the cluster", op, "any_tool");
554 assert_eq!(
555 result,
556 AnomalyResponse::Normal,
557 "admin intent should allow {op:?}"
558 );
559 }
560 }
561
562 #[test]
563 fn admin_intent_flags_delete_operations() {
564 let detector = AnomalyDetector::new(AnomalyConfig {
565 escalate_to_deny: false,
566 ..Default::default()
567 });
568
569 let result = detector.detect(
570 "manage the cluster",
571 OperationType::Delete,
572 "delete_resource",
573 );
574 assert!(
575 matches!(result, AnomalyResponse::Flagged { .. }),
576 "admin intent should flag delete operations, got: {result:?}"
577 );
578 }
579
580 #[test]
581 fn admin_intent_denies_delete_when_escalated() {
582 let detector = AnomalyDetector::new(AnomalyConfig {
583 escalate_to_deny: true,
584 ..Default::default()
585 });
586
587 let result = detector.detect(
588 "manage the cluster",
589 OperationType::Delete,
590 "delete_resource",
591 );
592 assert!(
593 matches!(result, AnomalyResponse::Denied { .. }),
594 "admin intent with escalation should deny delete operations, got: {result:?}"
595 );
596 }
597
598 #[test]
599 fn unknown_intent_flags_everything() {
600 let detector = AnomalyDetector::new(AnomalyConfig {
601 escalate_to_deny: false,
602 ..Default::default()
603 });
604
605 for op in [
606 OperationType::Read,
607 OperationType::Write,
608 OperationType::Delete,
609 OperationType::Admin,
610 ] {
611 let result = detector.detect("do something", op, "any_tool");
612 assert!(
613 matches!(result, AnomalyResponse::Flagged { .. }),
614 "unknown intent should flag {op:?}, got {result:?}"
615 );
616 }
617 }
618
619 #[test]
620 fn unknown_intent_denies_when_escalated() {
621 let detector = AnomalyDetector::new(AnomalyConfig {
622 escalate_to_deny: true,
623 ..Default::default()
624 });
625
626 for op in [
627 OperationType::Read,
628 OperationType::Write,
629 OperationType::Delete,
630 OperationType::Admin,
631 ] {
632 let result = detector.detect("do something", op, "any_tool");
633 assert!(
634 matches!(result, AnomalyResponse::Denied { .. }),
635 "unknown intent with escalation should deny {op:?}, got {result:?}"
636 );
637 }
638 }
639
640 #[test]
643 fn unknown_intent_scans_arguments_for_suspicious_patterns() {
644 let detector = AnomalyDetector::new(AnomalyConfig {
645 escalate_to_deny: false,
646 ..Default::default()
647 });
648
649 let args = serde_json::json!({"command": "rm -rf /"});
653 let result = detector.detect_with_args(
654 "do something", OperationType::Read,
656 "some_tool",
657 Some(&args),
658 );
659 assert!(
660 matches!(result, AnomalyResponse::Flagged { .. }),
661 "unknown intent should scan args for suspicious patterns, got {result:?}"
662 );
663 }
664
665 #[test]
667 fn unknown_intent_detects_structural_anomalies() {
668 let detector = AnomalyDetector::new(AnomalyConfig {
669 escalate_to_deny: false,
670 ..Default::default()
671 });
672
673 let args = serde_json::json!({"exec_command": "ls"});
675 let result = detector.detect_with_args(
676 "perform tasks", OperationType::Read,
678 "run_tool",
679 Some(&args),
680 );
681 assert!(
682 matches!(result, AnomalyResponse::Flagged { .. }),
683 "unknown intent should detect structural anomalies in args, got {result:?}"
684 );
685 }
686
687 #[test]
690 fn custom_keywords_case_insensitive() {
691 let detector = AnomalyDetector::new(AnomalyConfig::default());
692
693 assert_eq!(detector.classify_intent("READ FILES"), IntentTier::Read);
695
696 assert_eq!(detector.classify_intent("ANALYZE DATA"), IntentTier::Read);
698
699 assert_eq!(
701 detector.classify_intent("CREATE REPORTS"),
702 IntentTier::Write
703 );
704
705 assert_eq!(
707 detector.classify_intent("MANAGE SERVERS"),
708 IntentTier::Admin
709 );
710
711 assert_eq!(
713 detector.classify_intent("Read And Deploy"),
714 IntentTier::Write
715 );
716 }
717
718 #[test]
720 fn argument_evasion_destructive_args() {
721 let detector = AnomalyDetector::new(AnomalyConfig {
722 escalate_to_deny: true,
723 ..Default::default()
724 });
725 let args = serde_json::json!({"path": "/etc", "command": "rm -rf /"});
726 let result = detector.detect_with_args(
727 "read and analyze files",
728 OperationType::Read,
729 "read_file",
730 Some(&args),
731 );
732 assert!(!matches!(result, AnomalyResponse::Normal));
733 }
734
735 #[test]
737 fn argument_evasion_sql_injection() {
738 let detector = AnomalyDetector::new(AnomalyConfig::default());
739 let args = serde_json::json!({"query": "'; DROP TABLE users; --"});
740 let result = detector.detect_with_args(
741 "search the database",
742 OperationType::Read,
743 "search_records",
744 Some(&args),
745 );
746 assert!(!matches!(result, AnomalyResponse::Normal));
747 }
748
749 #[test]
753 fn configurable_patterns_trigger_detection() {
754 let detector = AnomalyDetector::new(AnomalyConfig {
755 suspicious_arg_patterns: vec!["super_secret_payload".into()],
756 ..Default::default()
757 });
758 let args = serde_json::json!({"data": "contains super_secret_payload here"});
759 let result = detector.detect_with_args(
760 "read the logs",
761 OperationType::Read,
762 "read_file",
763 Some(&args),
764 );
765 assert!(
766 matches!(result, AnomalyResponse::Flagged { ref reason } if reason.contains("super_secret_payload")),
767 "custom pattern should trigger flagging, got: {result:?}"
768 );
769 }
770
771 #[test]
775 fn nested_array_in_read_session_flagged() {
776 let detector = AnomalyDetector::new(AnomalyConfig::default());
777 let args = serde_json::json!({"files": ["a", "b"]});
778 let result = detector.detect_with_args(
779 "read the config",
780 OperationType::Read,
781 "read_file",
782 Some(&args),
783 );
784 assert!(
785 matches!(result, AnomalyResponse::Flagged { ref reason } if reason.contains("nested") && reason.contains("array")),
786 "nested array should be flagged, got: {result:?}"
787 );
788 }
789
790 #[test]
792 fn suspicious_key_in_read_session_flagged() {
793 let detector = AnomalyDetector::new(AnomalyConfig::default());
794 let args = serde_json::json!({"shell_command": "ls"});
795 let result = detector.detect_with_args(
796 "read the config",
797 OperationType::Read,
798 "read_file",
799 Some(&args),
800 );
801 assert!(
802 matches!(result, AnomalyResponse::Flagged { ref reason } if reason.contains("suspicious fragment")),
803 "suspicious key should be flagged, got: {result:?}"
804 );
805 }
806
807 #[test]
809 fn long_value_in_read_session_flagged() {
810 let detector = AnomalyDetector::new(AnomalyConfig::default());
811 let long_string = "A".repeat(1025);
812 let args = serde_json::json!({"payload": long_string});
813 let result = detector.detect_with_args(
814 "read the config",
815 OperationType::Read,
816 "read_file",
817 Some(&args),
818 );
819 assert!(
820 matches!(result, AnomalyResponse::Flagged { ref reason } if reason.contains("1025 bytes")),
821 "long value should be flagged, got: {result:?}"
822 );
823 }
824
825 #[test]
827 fn structural_checks_skip_admin_sessions() {
828 let detector = AnomalyDetector::new(AnomalyConfig::default());
829 let args = serde_json::json!({"files": ["a", "b"], "shell_command": "ls"});
830 let result = detector.detect_with_args(
831 "manage the servers",
832 OperationType::Read,
833 "read_file",
834 Some(&args),
835 );
836 assert_eq!(
837 result,
838 AnomalyResponse::Normal,
839 "admin session should not trigger structural checks"
840 );
841 }
842
843 #[test]
845 fn structural_checks_skip_write_sessions() {
846 let detector = AnomalyDetector::new(AnomalyConfig::default());
847 let args = serde_json::json!({"files": ["a", "b"], "shell_command": "ls"});
848 let result = detector.detect_with_args(
849 "create the documents",
850 OperationType::Read,
851 "read_file",
852 Some(&args),
853 );
854 assert_eq!(
855 result,
856 AnomalyResponse::Normal,
857 "write session should not trigger structural checks"
858 );
859 }
860
861 #[test]
863 fn normal_read_args_not_flagged() {
864 let detector = AnomalyDetector::new(AnomalyConfig::default());
865 let args = serde_json::json!({"path": "/etc/config", "recursive": true});
866 let result = detector.detect_with_args(
867 "read the config",
868 OperationType::Read,
869 "read_file",
870 Some(&args),
871 );
872 assert_eq!(
873 result,
874 AnomalyResponse::Normal,
875 "normal scalar args should not be flagged, got: {result:?}"
876 );
877 }
878}