Skip to main content

arbiter_behavior/
detector.rs

1//! Behavioral anomaly detector.
2//!
3//! Analyzes the declared intent of a session against the actual operation
4//! types being performed, flagging mismatches as anomalies.
5
6use regex::RegexSet;
7use serde::{Deserialize, Serialize};
8
9use crate::classifier::OperationType;
10
11/// Default read-intent keywords. Each indicates that a session's declared intent
12/// is read-only, and any write/delete/admin operations should be flagged.
13///
14/// Rationale for each keyword:
15/// - "read", "view", "inspect" -- explicit read operations
16/// - "analyze", "summarize", "review", "explain", "describe" -- comprehension tasks that consume but don't modify
17/// - "check", "search", "query", "list" -- enumeration/lookup operations
18fn 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
35/// Default write-intent keywords. Sessions matching these may perform read
36/// and write operations, but admin operations are flagged.
37///
38/// Rationale: these verbs imply data mutation but not system administration.
39fn 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
54/// Default admin-intent keywords. Sessions matching these may perform any
55/// operation; no anomaly is flagged regardless of operation type.
56///
57/// Rationale: these verbs imply system-level authority.
58fn 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/// The classified privilege tier of a session's declared intent.
72/// Used to determine which operation types are anomalous.
73///
74/// Precedence: Admin > Write > Read > Unknown.
75/// If multiple keyword sets match, the highest tier wins.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum IntentTier {
78    /// No intent keywords matched. Anomaly detection skipped.
79    Unknown,
80    /// Read-only intent: write/delete/admin ops are anomalous.
81    Read,
82    /// Write intent: admin ops are anomalous, read/write/delete are normal.
83    Write,
84    /// Admin intent: delete ops flagged, all others normal.
85    Admin,
86}
87
88/// Default suspicious argument patterns that trigger anomaly detection in read
89/// sessions. Each pattern is matched (case-insensitive) against the serialized
90/// JSON argument text.
91///
92/// Categories:
93/// - Destructive shell commands: "rm -rf", "rm -f", "mkfs", "dd if=", "chmod 777"
94/// - Destructive SQL: "drop table", "drop database", "delete from", "truncate table"
95/// - SQL injection fragments: "; --", "' or '1'='1", "union select"
96/// - Path traversal: "../../../", "..\\..\\"
97fn 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
116/// Suspicious argument key substrings. If any argument key in a read-intent
117/// session contains one of these substrings, the call is flagged.
118const SUSPICIOUS_ARG_KEY_FRAGMENTS: &[&str] = &[
119    "exec", "shell", "command", "query", "sql", "eval", "script", "code", "run", "system",
120];
121
122/// Maximum string value length (in bytes) allowed in a read-intent session
123/// before flagging as a potential payload injection.
124const MAX_READ_ARG_STRING_LEN: usize = 1024;
125
126/// Configuration for anomaly detection behavior.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct AnomalyConfig {
129    /// Whether anomalies should escalate to deny (hard block).
130    /// If false, anomalies are logged and flagged but the request proceeds.
131    #[serde(default)]
132    pub escalate_to_deny: bool,
133
134    /// Keywords that indicate a session's declared intent is read-only.
135    /// If the intent matches any of these (case-insensitive word boundary),
136    /// then write/delete/admin operations are flagged as anomalies.
137    #[serde(default = "default_read_intent_keywords")]
138    pub read_intent_keywords: Vec<String>,
139
140    /// Keywords that indicate a session's declared intent includes writes.
141    /// Write-intent sessions may perform read and write operations, but
142    /// admin operations are flagged.
143    #[serde(default = "default_write_intent_keywords")]
144    pub write_intent_keywords: Vec<String>,
145
146    /// Keywords that indicate a session's declared intent is administrative.
147    /// Admin-intent sessions may perform any operation without anomaly flags.
148    #[serde(default = "default_admin_intent_keywords")]
149    pub admin_intent_keywords: Vec<String>,
150
151    /// Suspicious argument patterns that trigger anomaly detection in read sessions.
152    /// Matched case-insensitively against the serialized JSON argument text.
153    #[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/// The result of anomaly detection on a tool call.
170#[derive(Debug, Clone, PartialEq, Eq)]
171pub enum AnomalyResponse {
172    /// No anomaly detected.
173    Normal,
174    /// Anomaly detected but request proceeds (soft flag).
175    Flagged {
176        /// Description of the anomaly.
177        reason: String,
178    },
179    /// Anomaly detected and request should be denied (hard block).
180    Denied {
181        /// Description of the anomaly.
182        reason: String,
183    },
184}
185
186/// Behavioral anomaly detector.
187pub struct AnomalyDetector {
188    config: AnomalyConfig,
189    /// Pre-compiled regex sets for intent word-boundary matching.
190    /// Built from config keywords at construction time. No per-call compilation.
191    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    /// Create a new anomaly detector with the given config.
206    /// Pre-compiles intent keywords into RegexSets for O(1) matching.
207    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    /// Classify the declared intent into a privilege tier.
223    /// Highest matching tier wins: Admin > Write > Read > Unknown.
224    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    /// Detect anomalies for a tool call given the session's declared intent.
237    ///
238    /// Tiered detection:
239    /// - Admin intent: delete ops flagged, all others normal
240    /// - Write intent: admin ops flagged, everything else normal
241    /// - Read intent: write/delete/admin ops flagged
242    /// - Unknown intent: no anomaly detection
243    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    /// Detect anomalies with argument-level scanning.
253    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            // Unknown intent: flag ALL operations. Unclassified intents receive
264            // maximum scrutiny — less specific intent declarations should trigger
265            // more monitoring, not less. Previously returned false (no monitoring),
266            // allowing trivial bypass by declaring non-keyword intents like "do stuff".
267            IntentTier::Unknown => true,
268            // Admin intent: flag delete operations. Admin sessions are powerful
269            // and deletion should always leave an anomaly trace for forensics.
270            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            // Argument scanning for destructive patterns applies to Read and
277            // Unknown tiers. Unknown-tier sessions should receive at least
278            // read-level scrutiny for destructive argument patterns.
279            if matches!(tier, IntentTier::Read | IntentTier::Unknown)
280                && let Some(args) = arguments
281            {
282                // Pattern-based scan against configurable suspicious patterns.
283                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                // Structural analysis for read/unknown-intent sessions.
299                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
338/// Check for structural anomalies in arguments for a read-intent session.
339///
340/// Read operations typically take simple scalar parameters (strings, numbers,
341/// booleans). Complex structures or known-dangerous key names suggest an
342/// attempt to smuggle write/execute semantics through a read-classified call.
343///
344/// Returns `Some(reason)` if an anomaly is found, `None` otherwise.
345fn 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        // 1. Nested objects/arrays: read ops should only have scalar params.
350        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        // 2. Suspicious argument key names.
360        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        // 3. Long string values (potential payload injection).
371        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        // Delete in a read session should also flag.
421        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    // ── Tiered intent classification tests ───────────────────────────
461
462    #[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        // "manage" (admin) + "read" (read) → admin wins
498        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        // "create" (write) + "read" (read) → write wins
509        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        // Write intent allows read and write operations.
523        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        // But admin ops are flagged.
533        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    /// RT-202: Unknown intent gets argument scanning (same as Read tier).
641    /// Suspicious arg patterns should be detected even when intent is unclassified.
642    #[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        // Unknown intent + Read operation + suspicious "rm -rf" pattern in args.
650        // Even though the operation type alone wouldn't trigger (Read is normal),
651        // the argument scanning should catch the suspicious pattern.
652        let args = serde_json::json!({"command": "rm -rf /"});
653        let result = detector.detect_with_args(
654            "do something", // Unknown intent (no keyword match)
655            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    /// Unknown intent triggers structural anomaly detection (suspicious key names).
666    #[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        // Argument with suspicious key name "exec_command" (contains "exec" fragment).
674        let args = serde_json::json!({"exec_command": "ls"});
675        let result = detector.detect_with_args(
676            "perform tasks", // Unknown intent
677            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    /// Intent classification uses case-insensitive word-boundary matching,
688    /// so uppercase keywords should still match their respective tiers.
689    #[test]
690    fn custom_keywords_case_insensitive() {
691        let detector = AnomalyDetector::new(AnomalyConfig::default());
692
693        // "READ" (uppercase) should match the read tier
694        assert_eq!(detector.classify_intent("READ FILES"), IntentTier::Read);
695
696        // "ANALYZE" (uppercase) should also match read tier
697        assert_eq!(detector.classify_intent("ANALYZE DATA"), IntentTier::Read);
698
699        // "CREATE" (uppercase) should match write tier
700        assert_eq!(
701            detector.classify_intent("CREATE REPORTS"),
702            IntentTier::Write
703        );
704
705        // "MANAGE" (uppercase) should match admin tier
706        assert_eq!(
707            detector.classify_intent("MANAGE SERVERS"),
708            IntentTier::Admin
709        );
710
711        // Mixed case
712        assert_eq!(
713            detector.classify_intent("Read And Deploy"),
714            IntentTier::Write
715        );
716    }
717
718    /// Destructive arguments in read session must be flagged.
719    #[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    /// SQL injection in arguments must be flagged.
736    #[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    // ── Configurable argument patterns ──────────────────────────────
750
751    /// Custom pattern supplied via AnomalyConfig triggers detection.
752    #[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    // ── Structural argument analysis (read-intent only) ─────────────
772
773    /// Nested array in a read session should be flagged.
774    #[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    /// Suspicious key name in a read session should be flagged.
791    #[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    /// String value > 1KB in a read session should be flagged.
808    #[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    /// Structural checks should NOT fire for admin-intent sessions.
826    #[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    /// Structural checks should NOT fire for write-intent sessions.
844    #[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    /// Normal scalar arguments in a read session should NOT be flagged.
862    #[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}