1use async_trait::async_trait;
5use chrono::{DateTime, Utc};
6use rustant_core::error::ToolError;
7use rustant_core::types::{RiskLevel, ToolOutput};
8use serde::{Deserialize, Serialize};
9use serde_json::{Value, json};
10use std::path::PathBuf;
11use std::time::Duration;
12
13use crate::registry::Tool;
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
16enum BoundaryType {
17 LocalOnly,
18 Encrypted,
19 Shareable,
20}
21
22impl BoundaryType {
23 fn from_str(s: &str) -> Option<Self> {
24 match s {
25 "local_only" => Some(BoundaryType::LocalOnly),
26 "encrypted" => Some(BoundaryType::Encrypted),
27 "shareable" => Some(BoundaryType::Shareable),
28 _ => None,
29 }
30 }
31
32 fn as_str(&self) -> &str {
33 match self {
34 BoundaryType::LocalOnly => "local_only",
35 BoundaryType::Encrypted => "encrypted",
36 BoundaryType::Shareable => "shareable",
37 }
38 }
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42struct DataBoundary {
43 id: usize,
44 name: String,
45 boundary_type: BoundaryType,
46 paths: Vec<String>,
47 description: String,
48 created_at: DateTime<Utc>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
52struct AccessLogEntry {
53 timestamp: DateTime<Utc>,
54 tool_name: String,
55 data_accessed: String,
56 purpose: String,
57 boundary_id: Option<usize>,
58}
59
60#[derive(Debug, Serialize, Deserialize)]
61struct PrivacyState {
62 boundaries: Vec<DataBoundary>,
63 access_log: Vec<AccessLogEntry>,
64 next_id: usize,
65 max_log_entries: usize,
66}
67
68impl Default for PrivacyState {
69 fn default() -> Self {
70 Self {
71 boundaries: Vec::new(),
72 access_log: Vec::new(),
73 next_id: 1,
74 max_log_entries: 10_000,
75 }
76 }
77}
78
79pub struct PrivacyManagerTool {
80 workspace: PathBuf,
81}
82
83impl PrivacyManagerTool {
84 pub fn new(workspace: PathBuf) -> Self {
85 Self { workspace }
86 }
87
88 fn state_path(&self) -> PathBuf {
89 self.workspace
90 .join(".rustant")
91 .join("privacy")
92 .join("config.json")
93 }
94
95 fn load_state(&self) -> PrivacyState {
96 let path = self.state_path();
97 if path.exists() {
98 std::fs::read_to_string(&path)
99 .ok()
100 .and_then(|s| serde_json::from_str(&s).ok())
101 .unwrap_or_default()
102 } else {
103 PrivacyState::default()
104 }
105 }
106
107 fn save_state(&self, state: &PrivacyState) -> Result<(), ToolError> {
108 let path = self.state_path();
109 if let Some(parent) = path.parent() {
110 std::fs::create_dir_all(parent).map_err(|e| ToolError::ExecutionFailed {
111 name: "privacy_manager".to_string(),
112 message: format!("Failed to create dir: {}", e),
113 })?;
114 }
115 let json = serde_json::to_string_pretty(state).map_err(|e| ToolError::ExecutionFailed {
116 name: "privacy_manager".to_string(),
117 message: format!("Serialize error: {}", e),
118 })?;
119 let tmp = path.with_extension("json.tmp");
120 std::fs::write(&tmp, &json).map_err(|e| ToolError::ExecutionFailed {
121 name: "privacy_manager".to_string(),
122 message: format!("Write error: {}", e),
123 })?;
124 std::fs::rename(&tmp, &path).map_err(|e| ToolError::ExecutionFailed {
125 name: "privacy_manager".to_string(),
126 message: format!("Rename error: {}", e),
127 })?;
128 Ok(())
129 }
130
131 fn rustant_dir(&self) -> PathBuf {
132 self.workspace.join(".rustant")
133 }
134
135 fn dir_stats(&self, path: &std::path::Path) -> (u64, usize) {
137 let mut total_size: u64 = 0;
138 let mut file_count: usize = 0;
139 if let Ok(entries) = std::fs::read_dir(path) {
140 for entry in entries.flatten() {
141 let entry_path = entry.path();
142 if entry_path.is_dir() {
143 let (s, c) = self.dir_stats(&entry_path);
144 total_size += s;
145 file_count += c;
146 } else if entry_path.is_file()
147 && let Ok(meta) = entry_path.metadata()
148 {
149 total_size += meta.len();
150 file_count += 1;
151 }
152 }
153 }
154 (total_size, file_count)
155 }
156
157 fn list_domains(&self) -> Vec<String> {
159 let rustant_dir = self.rustant_dir();
160 let mut domains = Vec::new();
161 if let Ok(entries) = std::fs::read_dir(&rustant_dir) {
162 for entry in entries.flatten() {
163 if entry.path().is_dir()
164 && let Some(name) = entry.file_name().to_str()
165 {
166 domains.push(name.to_string());
167 }
168 }
169 }
170 domains.sort();
171 domains
172 }
173
174 fn collect_all_paths(&self) -> Vec<String> {
176 let rustant_dir = self.rustant_dir();
177 let mut paths = Vec::new();
178 self.collect_paths_recursive(&rustant_dir, &rustant_dir, &mut paths);
179 paths
180 }
181
182 fn collect_paths_recursive(
183 &self,
184 base: &std::path::Path,
185 current: &std::path::Path,
186 out: &mut Vec<String>,
187 ) {
188 if let Ok(entries) = std::fs::read_dir(current) {
189 for entry in entries.flatten() {
190 let entry_path = entry.path();
191 if let Ok(rel) = entry_path.strip_prefix(base) {
192 let rel_str = rel.to_string_lossy().to_string();
193 out.push(rel_str);
194 }
195 if entry_path.is_dir() {
196 self.collect_paths_recursive(base, &entry_path, out);
197 }
198 }
199 }
200 }
201
202 fn path_covered_by_boundary(
204 &self,
205 rel_path: &str,
206 boundaries: &[DataBoundary],
207 ) -> Option<usize> {
208 for boundary in boundaries {
209 for bp in &boundary.paths {
210 if rel_path.starts_with(bp.as_str()) || rel_path == *bp {
211 return Some(boundary.id);
212 }
213 }
214 }
215 None
216 }
217
218 fn format_size(bytes: u64) -> String {
219 if bytes < 1024 {
220 format!("{} B", bytes)
221 } else if bytes < 1024 * 1024 {
222 format!("{:.1} KB", bytes as f64 / 1024.0)
223 } else {
224 format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
225 }
226 }
227
228 fn delete_dir_contents(&self, path: &std::path::Path) -> Result<usize, ToolError> {
230 let mut deleted = 0;
231 if let Ok(entries) = std::fs::read_dir(path) {
232 for entry in entries.flatten() {
233 let entry_path = entry.path();
234 if entry_path.is_dir() {
235 std::fs::remove_dir_all(&entry_path).map_err(|e| {
236 ToolError::ExecutionFailed {
237 name: "privacy_manager".to_string(),
238 message: format!("Failed to remove {}: {}", entry_path.display(), e),
239 }
240 })?;
241 deleted += 1;
242 } else {
243 std::fs::remove_file(&entry_path).map_err(|e| ToolError::ExecutionFailed {
244 name: "privacy_manager".to_string(),
245 message: format!("Failed to remove {}: {}", entry_path.display(), e),
246 })?;
247 deleted += 1;
248 }
249 }
250 }
251 Ok(deleted)
252 }
253
254 fn action_set_boundary(&self, args: &Value) -> Result<ToolOutput, ToolError> {
257 let name = args
258 .get("name")
259 .and_then(|v| v.as_str())
260 .unwrap_or("")
261 .trim();
262 if name.is_empty() {
263 return Ok(ToolOutput::text(
264 "Error: 'name' is required for set_boundary.",
265 ));
266 }
267
268 let boundary_type_str = args
269 .get("boundary_type")
270 .and_then(|v| v.as_str())
271 .unwrap_or("");
272 let boundary_type = match BoundaryType::from_str(boundary_type_str) {
273 Some(bt) => bt,
274 None => {
275 return Ok(ToolOutput::text(format!(
276 "Error: invalid boundary_type '{}'. Use: local_only, encrypted, shareable",
277 boundary_type_str
278 )));
279 }
280 };
281
282 let paths: Vec<String> = match args.get("paths") {
283 Some(Value::Array(arr)) => arr
284 .iter()
285 .filter_map(|v| v.as_str().map(|s| s.to_string()))
286 .collect(),
287 _ => {
288 return Ok(ToolOutput::text(
289 "Error: 'paths' is required as an array of strings.",
290 ));
291 }
292 };
293 if paths.is_empty() {
294 return Ok(ToolOutput::text(
295 "Error: 'paths' must contain at least one path.",
296 ));
297 }
298
299 let description = args
300 .get("description")
301 .and_then(|v| v.as_str())
302 .unwrap_or("")
303 .to_string();
304
305 let mut state = self.load_state();
306 let id = state.next_id;
307 state.next_id += 1;
308
309 state.boundaries.push(DataBoundary {
310 id,
311 name: name.to_string(),
312 boundary_type,
313 paths: paths.clone(),
314 description,
315 created_at: Utc::now(),
316 });
317 self.save_state(&state)?;
318
319 Ok(ToolOutput::text(format!(
320 "Created data boundary #{} '{}' ({}) covering {} path(s).",
321 id,
322 name,
323 boundary_type_str,
324 paths.len()
325 )))
326 }
327
328 fn action_list_boundaries(&self) -> Result<ToolOutput, ToolError> {
329 let state = self.load_state();
330 if state.boundaries.is_empty() {
331 return Ok(ToolOutput::text("No data boundaries defined."));
332 }
333
334 let mut lines = Vec::new();
335 lines.push(format!("Data boundaries ({}):", state.boundaries.len()));
336 for b in &state.boundaries {
337 lines.push(format!(
338 " #{} — {} [{}]",
339 b.id,
340 b.name,
341 b.boundary_type.as_str()
342 ));
343 for p in &b.paths {
344 lines.push(format!(" path: {}", p));
345 }
346 if !b.description.is_empty() {
347 lines.push(format!(" desc: {}", b.description));
348 }
349 }
350 Ok(ToolOutput::text(lines.join("\n")))
351 }
352
353 fn action_audit_access(&self, args: &Value) -> Result<ToolOutput, ToolError> {
354 let state = self.load_state();
355 let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
356 let tool_filter = args.get("tool_name").and_then(|v| v.as_str());
357 let boundary_filter = args
358 .get("boundary_id")
359 .and_then(|v| v.as_u64())
360 .map(|v| v as usize);
361
362 let filtered: Vec<&AccessLogEntry> = state
363 .access_log
364 .iter()
365 .rev()
366 .filter(|e| {
367 if let Some(tn) = tool_filter
368 && e.tool_name != tn
369 {
370 return false;
371 }
372 if let Some(bid) = boundary_filter
373 && e.boundary_id != Some(bid)
374 {
375 return false;
376 }
377 true
378 })
379 .take(limit)
380 .collect();
381
382 if filtered.is_empty() {
383 return Ok(ToolOutput::text("No access log entries found."));
384 }
385
386 let mut lines = Vec::new();
387 lines.push(format!("Access log ({} entries shown):", filtered.len()));
388 for entry in &filtered {
389 let boundary_note = if let Some(bid) = entry.boundary_id {
390 format!(" [boundary #{}]", bid)
391 } else {
392 String::new()
393 };
394 lines.push(format!(
395 " {} — {} accessed '{}' for '{}'{}",
396 entry.timestamp.format("%Y-%m-%d %H:%M:%S"),
397 entry.tool_name,
398 entry.data_accessed,
399 entry.purpose,
400 boundary_note
401 ));
402 }
403 Ok(ToolOutput::text(lines.join("\n")))
404 }
405
406 fn action_compliance_check(&self) -> Result<ToolOutput, ToolError> {
407 let state = self.load_state();
408 let rustant_dir = self.rustant_dir();
409 if !rustant_dir.exists() {
410 return Ok(ToolOutput::text(
411 "No .rustant/ directory found. Nothing to check.",
412 ));
413 }
414
415 let domains = self.list_domains();
416 if domains.is_empty() {
417 return Ok(ToolOutput::text(
418 "No data directories found in .rustant/. Compliance check complete — nothing to cover.",
419 ));
420 }
421
422 let all_paths = self.collect_all_paths();
423 let mut covered_count = 0;
424 let mut uncovered_dirs: Vec<String> = Vec::new();
425
426 for domain in &domains {
427 if self
428 .path_covered_by_boundary(domain, &state.boundaries)
429 .is_some()
430 {
431 covered_count += 1;
432 } else {
433 uncovered_dirs.push(domain.clone());
434 }
435 }
436
437 let total = domains.len();
438 let coverage_pct = if total > 0 {
439 (covered_count as f64 / total as f64) * 100.0
440 } else {
441 100.0
442 };
443
444 let mut lines = Vec::new();
445 lines.push("Compliance Check Report".to_string());
446 lines.push(format!(" Total directories: {}", total));
447 lines.push(format!(" Covered by boundaries: {}", covered_count));
448 lines.push(format!(" Coverage: {:.0}%", coverage_pct));
449 lines.push(format!(" Total paths scanned: {}", all_paths.len()));
450
451 if !uncovered_dirs.is_empty() {
452 lines.push(String::new());
453 lines.push(" Uncovered directories:".to_string());
454 for d in &uncovered_dirs {
455 lines.push(format!(" - {}", d));
456 }
457 lines.push(String::new());
458 lines
459 .push(" Recommendation: Create boundaries for uncovered directories.".to_string());
460 } else {
461 lines.push(String::new());
462 lines.push(" All directories are covered by boundaries.".to_string());
463 }
464
465 Ok(ToolOutput::text(lines.join("\n")))
466 }
467
468 fn action_export_data(&self, args: &Value) -> Result<ToolOutput, ToolError> {
469 let output_name = args
470 .get("output")
471 .and_then(|v| v.as_str())
472 .unwrap_or("rustant_export.json");
473
474 let rustant_dir = self.rustant_dir();
475 if !rustant_dir.exists() {
476 return Ok(ToolOutput::text(
477 "No .rustant/ directory found. Nothing to export.",
478 ));
479 }
480
481 let mut export = serde_json::Map::new();
482 let domains = self.list_domains();
483
484 for domain in &domains {
485 let domain_dir = rustant_dir.join(domain);
486 let mut domain_files = serde_json::Map::new();
487
488 if let Ok(entries) = std::fs::read_dir(&domain_dir) {
489 for entry in entries.flatten() {
490 let entry_path = entry.path();
491 if entry_path.is_file()
492 && let Some(fname) = entry_path.file_name().and_then(|f| f.to_str())
493 {
494 match std::fs::read_to_string(&entry_path) {
495 Ok(content) => {
496 if let Ok(val) = serde_json::from_str::<Value>(&content) {
498 domain_files.insert(fname.to_string(), val);
499 } else {
500 domain_files.insert(fname.to_string(), Value::String(content));
501 }
502 }
503 Err(_) => {
504 domain_files.insert(
505 fname.to_string(),
506 Value::String("[binary or unreadable]".to_string()),
507 );
508 }
509 }
510 }
511 }
512 }
513 export.insert(domain.clone(), Value::Object(domain_files));
514 }
515
516 let export_json =
517 serde_json::to_string_pretty(&export).map_err(|e| ToolError::ExecutionFailed {
518 name: "privacy_manager".to_string(),
519 message: format!("Failed to serialize export: {}", e),
520 })?;
521
522 if export_json.len() < 50_000 {
524 Ok(ToolOutput::text(format!(
525 "Exported {} domain(s) ({} bytes):\n{}",
526 domains.len(),
527 export_json.len(),
528 export_json
529 )))
530 } else {
531 let output_path = self.workspace.join(output_name);
532 std::fs::write(&output_path, &export_json).map_err(|e| ToolError::ExecutionFailed {
533 name: "privacy_manager".to_string(),
534 message: format!("Failed to write export file: {}", e),
535 })?;
536 Ok(ToolOutput::text(format!(
537 "Exported {} domain(s) to {}. Size: {}",
538 domains.len(),
539 output_path.display(),
540 Self::format_size(export_json.len() as u64)
541 )))
542 }
543 }
544
545 fn action_delete_data(&self, args: &Value) -> Result<ToolOutput, ToolError> {
546 let domain = args
547 .get("domain")
548 .and_then(|v| v.as_str())
549 .unwrap_or("")
550 .trim();
551 if domain.is_empty() {
552 return Ok(ToolOutput::text(
553 "Error: 'domain' is required for delete_data. Use a domain name or 'all'.",
554 ));
555 }
556
557 let rustant_dir = self.rustant_dir();
558 if !rustant_dir.exists() {
559 return Ok(ToolOutput::text("No .rustant/ directory found."));
560 }
561
562 if domain == "all" {
563 let mut deleted_total = 0;
564 let mut deleted_domains = Vec::new();
565 if let Ok(entries) = std::fs::read_dir(&rustant_dir) {
566 for entry in entries.flatten() {
567 let entry_path = entry.path();
568 if entry_path.is_dir() {
569 let dir_name = entry.file_name().to_str().unwrap_or("").to_string();
570 if dir_name == "privacy" {
572 continue;
573 }
574 let count = self.delete_dir_contents(&entry_path)?;
575 std::fs::remove_dir_all(&entry_path).map_err(|e| {
576 ToolError::ExecutionFailed {
577 name: "privacy_manager".to_string(),
578 message: format!("Failed to remove dir {}: {}", dir_name, e),
579 }
580 })?;
581 deleted_total += count + 1; deleted_domains.push(dir_name);
583 } else if entry_path.is_file() {
584 let fname = entry.file_name().to_str().unwrap_or("").to_string();
586 std::fs::remove_file(&entry_path).map_err(|e| {
587 ToolError::ExecutionFailed {
588 name: "privacy_manager".to_string(),
589 message: format!("Failed to remove file {}: {}", fname, e),
590 }
591 })?;
592 deleted_total += 1;
593 }
594 }
595 }
596 Ok(ToolOutput::text(format!(
597 "Deleted all data except privacy config. Removed {} item(s) across domain(s): {}",
598 deleted_total,
599 if deleted_domains.is_empty() {
600 "none".to_string()
601 } else {
602 deleted_domains.join(", ")
603 }
604 )))
605 } else {
606 let domain_dir = rustant_dir.join(domain);
607 if !domain_dir.exists() || !domain_dir.is_dir() {
608 return Ok(ToolOutput::text(format!(
609 "Domain '{}' not found in .rustant/.",
610 domain
611 )));
612 }
613 let count = self.delete_dir_contents(&domain_dir)?;
614 std::fs::remove_dir_all(&domain_dir).map_err(|e| ToolError::ExecutionFailed {
615 name: "privacy_manager".to_string(),
616 message: format!("Failed to remove domain dir: {}", e),
617 })?;
618 Ok(ToolOutput::text(format!(
619 "Deleted domain '{}': removed {} item(s).",
620 domain, count
621 )))
622 }
623 }
624
625 fn action_encrypt_store(&self, args: &Value) -> Result<ToolOutput, ToolError> {
626 let path_str = args
627 .get("path")
628 .and_then(|v| v.as_str())
629 .unwrap_or("")
630 .trim()
631 .to_string();
632 if path_str.is_empty() {
633 return Ok(ToolOutput::text(
634 "Error: 'path' is required for encrypt_store.",
635 ));
636 }
637
638 let file_path = if std::path::Path::new(&path_str).is_absolute() {
640 PathBuf::from(&path_str)
641 } else {
642 self.rustant_dir().join(&path_str)
643 };
644
645 if !file_path.exists() || !file_path.is_file() {
646 return Ok(ToolOutput::text(format!(
647 "Error: file '{}' not found.",
648 file_path.display()
649 )));
650 }
651
652 let content = std::fs::read(&file_path).map_err(|e| ToolError::ExecutionFailed {
653 name: "privacy_manager".to_string(),
654 message: format!("Failed to read file: {}", e),
655 })?;
656
657 let encoded = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &content);
658
659 let encrypted_path = file_path.with_extension(format!(
660 "{}.encrypted",
661 file_path
662 .extension()
663 .and_then(|e| e.to_str())
664 .unwrap_or("dat")
665 ));
666
667 let output_content = format!(
668 "# Rustant encrypted store (base64 placeholder)\n\
669 # TODO: Replace with AES-256-GCM encryption (future crate)\n\
670 # Original: {}\n\
671 # Encrypted at: {}\n\
672 {}\n",
673 file_path.display(),
674 Utc::now().format("%Y-%m-%d %H:%M:%S UTC"),
675 encoded
676 );
677
678 std::fs::write(&encrypted_path, output_content.as_bytes()).map_err(|e| {
679 ToolError::ExecutionFailed {
680 name: "privacy_manager".to_string(),
681 message: format!("Failed to write encrypted file: {}", e),
682 }
683 })?;
684
685 Ok(ToolOutput::text(format!(
686 "Encrypted (base64) '{}' -> '{}'. Note: this is a base64 placeholder; \
687 AES-256-GCM encryption will be added in a future release.",
688 file_path.display(),
689 encrypted_path.display()
690 )))
691 }
692
693 fn action_privacy_report(&self) -> Result<ToolOutput, ToolError> {
694 let state = self.load_state();
695 let rustant_dir = self.rustant_dir();
696 if !rustant_dir.exists() {
697 return Ok(ToolOutput::text(
698 "No .rustant/ directory found. Nothing to report.",
699 ));
700 }
701
702 let (total_size, total_files) = self.dir_stats(&rustant_dir);
703 let domains = self.list_domains();
704
705 let mut lines = Vec::new();
706 lines.push("Privacy Report".to_string());
707 lines.push("==============".to_string());
708 lines.push(String::new());
709 lines.push(format!(
710 "Total data size: {} ({} files)",
711 Self::format_size(total_size),
712 total_files
713 ));
714 lines.push(format!("Domains: {}", domains.len()));
715
716 if !domains.is_empty() {
718 lines.push(String::new());
719 lines.push("Domain breakdown:".to_string());
720 for domain in &domains {
721 let domain_dir = rustant_dir.join(domain);
722 let (size, count) = self.dir_stats(&domain_dir);
723 let covered = self
724 .path_covered_by_boundary(domain, &state.boundaries)
725 .is_some();
726 let coverage_tag = if covered {
727 " [covered]"
728 } else {
729 " [uncovered]"
730 };
731 lines.push(format!(
732 " {} — {} ({} files){}",
733 domain,
734 Self::format_size(size),
735 count,
736 coverage_tag
737 ));
738 }
739 }
740
741 let covered_count = domains
743 .iter()
744 .filter(|d| {
745 self.path_covered_by_boundary(d, &state.boundaries)
746 .is_some()
747 })
748 .count();
749 let coverage_pct = if domains.is_empty() {
750 100.0
751 } else {
752 (covered_count as f64 / domains.len() as f64) * 100.0
753 };
754 lines.push(String::new());
755 lines.push(format!("Boundary coverage: {:.0}%", coverage_pct));
756 lines.push(format!("Boundaries defined: {}", state.boundaries.len()));
757
758 lines.push(String::new());
760 lines.push("Access log:".to_string());
761 lines.push(format!(" Total entries: {}", state.access_log.len()));
762 let unique_tools: std::collections::HashSet<&str> = state
763 .access_log
764 .iter()
765 .map(|e| e.tool_name.as_str())
766 .collect();
767 lines.push(format!(" Unique tools: {}", unique_tools.len()));
768
769 let uncovered: Vec<&String> = domains
771 .iter()
772 .filter(|d| {
773 self.path_covered_by_boundary(d, &state.boundaries)
774 .is_none()
775 })
776 .collect();
777 if !uncovered.is_empty() {
778 lines.push(String::new());
779 lines.push("Recommendations:".to_string());
780 for d in &uncovered {
781 lines.push(format!(
782 " - Create a boundary for '{}' to control data access",
783 d
784 ));
785 }
786 }
787
788 Ok(ToolOutput::text(lines.join("\n")))
789 }
790}
791
792#[async_trait]
793impl Tool for PrivacyManagerTool {
794 fn name(&self) -> &str {
795 "privacy_manager"
796 }
797
798 fn description(&self) -> &str {
799 "Privacy and data sovereignty: boundaries, access auditing, data export/deletion. Actions: set_boundary, list_boundaries, audit_access, compliance_check, export_data, delete_data, encrypt_store, privacy_report."
800 }
801
802 fn parameters_schema(&self) -> Value {
803 json!({
804 "type": "object",
805 "properties": {
806 "action": {
807 "type": "string",
808 "enum": [
809 "set_boundary", "list_boundaries", "audit_access",
810 "compliance_check", "export_data", "delete_data",
811 "encrypt_store", "privacy_report"
812 ],
813 "description": "Action to perform"
814 },
815 "name": {
816 "type": "string",
817 "description": "Boundary name (for set_boundary)"
818 },
819 "boundary_type": {
820 "type": "string",
821 "enum": ["local_only", "encrypted", "shareable"],
822 "description": "Boundary type (for set_boundary)"
823 },
824 "paths": {
825 "type": "array",
826 "items": { "type": "string" },
827 "description": "Paths covered by the boundary (for set_boundary)"
828 },
829 "description": {
830 "type": "string",
831 "description": "Boundary description (for set_boundary)"
832 },
833 "limit": {
834 "type": "integer",
835 "description": "Max entries to return (for audit_access, default 50)"
836 },
837 "tool_name": {
838 "type": "string",
839 "description": "Filter by tool name (for audit_access)"
840 },
841 "boundary_id": {
842 "type": "integer",
843 "description": "Filter by boundary ID (for audit_access)"
844 },
845 "output": {
846 "type": "string",
847 "description": "Output filename (for export_data, default rustant_export.json)"
848 },
849 "domain": {
850 "type": "string",
851 "description": "Domain name or 'all' (for delete_data)"
852 },
853 "path": {
854 "type": "string",
855 "description": "File path to encrypt (for encrypt_store)"
856 }
857 },
858 "required": ["action"]
859 })
860 }
861
862 fn risk_level(&self) -> RiskLevel {
863 RiskLevel::Write
864 }
865
866 fn timeout(&self) -> Duration {
867 Duration::from_secs(60)
868 }
869
870 async fn execute(&self, args: Value) -> Result<ToolOutput, ToolError> {
871 let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("");
872
873 match action {
874 "set_boundary" => self.action_set_boundary(&args),
875 "list_boundaries" => self.action_list_boundaries(),
876 "audit_access" => self.action_audit_access(&args),
877 "compliance_check" => self.action_compliance_check(),
878 "export_data" => self.action_export_data(&args),
879 "delete_data" => self.action_delete_data(&args),
880 "encrypt_store" => self.action_encrypt_store(&args),
881 "privacy_report" => self.action_privacy_report(),
882 _ => Ok(ToolOutput::text(format!(
883 "Unknown action: '{}'. Use: set_boundary, list_boundaries, audit_access, \
884 compliance_check, export_data, delete_data, encrypt_store, privacy_report",
885 action
886 ))),
887 }
888 }
889}
890
891#[cfg(test)]
892mod tests {
893 use super::*;
894 use tempfile::TempDir;
895
896 fn setup() -> (TempDir, PathBuf) {
897 let dir = TempDir::new().unwrap();
898 let workspace = dir.path().canonicalize().unwrap();
899 (dir, workspace)
900 }
901
902 #[test]
903 fn test_tool_properties() {
904 let (_dir, workspace) = setup();
905 let tool = PrivacyManagerTool::new(workspace);
906 assert_eq!(tool.name(), "privacy_manager");
907 assert!(tool.description().contains("Privacy"));
908 assert_eq!(tool.risk_level(), RiskLevel::Write);
909 assert_eq!(tool.timeout(), Duration::from_secs(60));
910 }
911
912 #[test]
913 fn test_schema_validation() {
914 let (_dir, workspace) = setup();
915 let tool = PrivacyManagerTool::new(workspace);
916 let schema = tool.parameters_schema();
917 assert!(schema.get("properties").is_some());
918 let props = schema.get("properties").unwrap();
919 assert!(props.get("action").is_some());
920 assert!(props.get("name").is_some());
921 assert!(props.get("boundary_type").is_some());
922 assert!(props.get("paths").is_some());
923 assert!(props.get("domain").is_some());
924 assert!(props.get("path").is_some());
925 let action_enum = props["action"]["enum"].as_array().unwrap();
926 assert_eq!(action_enum.len(), 8);
927 let required = schema["required"].as_array().unwrap();
928 assert_eq!(required.len(), 1);
929 assert_eq!(required[0], "action");
930 }
931
932 #[tokio::test]
933 async fn test_set_boundary() {
934 let (_dir, workspace) = setup();
935 let tool = PrivacyManagerTool::new(workspace);
936
937 let result = tool
938 .execute(json!({
939 "action": "set_boundary",
940 "name": "personal_data",
941 "boundary_type": "local_only",
942 "paths": ["inbox", "relationships"],
943 "description": "Personal data stays local"
944 }))
945 .await
946 .unwrap();
947 assert!(result.content.contains("#1"));
948 assert!(result.content.contains("personal_data"));
949 assert!(result.content.contains("2 path(s)"));
950
951 let list = tool
953 .execute(json!({"action": "list_boundaries"}))
954 .await
955 .unwrap();
956 assert!(list.content.contains("personal_data"));
957 assert!(list.content.contains("local_only"));
958 assert!(list.content.contains("inbox"));
959 assert!(list.content.contains("relationships"));
960 }
961
962 #[tokio::test]
963 async fn test_list_boundaries_empty() {
964 let (_dir, workspace) = setup();
965 let tool = PrivacyManagerTool::new(workspace);
966
967 let result = tool
968 .execute(json!({"action": "list_boundaries"}))
969 .await
970 .unwrap();
971 assert!(result.content.contains("No data boundaries"));
972 }
973
974 #[tokio::test]
975 async fn test_audit_access_empty() {
976 let (_dir, workspace) = setup();
977 let tool = PrivacyManagerTool::new(workspace);
978
979 let result = tool
980 .execute(json!({"action": "audit_access"}))
981 .await
982 .unwrap();
983 assert!(result.content.contains("No access log entries"));
984 }
985
986 #[tokio::test]
987 async fn test_compliance_check_no_data() {
988 let (_dir, workspace) = setup();
989 let tool = PrivacyManagerTool::new(workspace.clone());
990
991 std::fs::create_dir_all(workspace.join(".rustant")).unwrap();
993
994 let result = tool
995 .execute(json!({"action": "compliance_check"}))
996 .await
997 .unwrap();
998 assert!(
999 result.content.contains("Nothing to cover")
1000 || result.content.contains("nothing to cover")
1001 );
1002 }
1003
1004 #[tokio::test]
1005 async fn test_compliance_check_with_boundary() {
1006 let (_dir, workspace) = setup();
1007 let tool = PrivacyManagerTool::new(workspace.clone());
1008
1009 std::fs::create_dir_all(workspace.join(".rustant").join("career")).unwrap();
1011 std::fs::create_dir_all(workspace.join(".rustant").join("inbox")).unwrap();
1012
1013 tool.execute(json!({
1015 "action": "set_boundary",
1016 "name": "career_data",
1017 "boundary_type": "encrypted",
1018 "paths": ["career"]
1019 }))
1020 .await
1021 .unwrap();
1022
1023 let result = tool
1024 .execute(json!({"action": "compliance_check"}))
1025 .await
1026 .unwrap();
1027 assert!(result.content.contains("Compliance Check Report"));
1028 assert!(result.content.contains("Uncovered directories"));
1031 assert!(result.content.contains("inbox"));
1032 }
1033
1034 #[tokio::test]
1035 async fn test_delete_data_domain() {
1036 let (_dir, workspace) = setup();
1037 let tool = PrivacyManagerTool::new(workspace.clone());
1038
1039 let domain_dir = workspace.join(".rustant").join("test_domain");
1041 std::fs::create_dir_all(&domain_dir).unwrap();
1042 std::fs::write(domain_dir.join("data.json"), r#"{"key": "value"}"#).unwrap();
1043 assert!(domain_dir.join("data.json").exists());
1044
1045 let result = tool
1046 .execute(json!({"action": "delete_data", "domain": "test_domain"}))
1047 .await
1048 .unwrap();
1049 assert!(result.content.contains("Deleted domain 'test_domain'"));
1050 assert!(result.content.contains("removed"));
1051 assert!(!domain_dir.exists());
1052 }
1053
1054 #[tokio::test]
1055 async fn test_export_data() {
1056 let (_dir, workspace) = setup();
1057 let tool = PrivacyManagerTool::new(workspace.clone());
1058
1059 let career_dir = workspace.join(".rustant").join("career");
1061 std::fs::create_dir_all(&career_dir).unwrap();
1062 std::fs::write(
1063 career_dir.join("goals.json"),
1064 r#"{"goals": ["learn rust"]}"#,
1065 )
1066 .unwrap();
1067
1068 let inbox_dir = workspace.join(".rustant").join("inbox");
1069 std::fs::create_dir_all(&inbox_dir).unwrap();
1070 std::fs::write(inbox_dir.join("items.json"), r#"{"items": []}"#).unwrap();
1071
1072 let result = tool
1073 .execute(json!({"action": "export_data"}))
1074 .await
1075 .unwrap();
1076 assert!(result.content.contains("Exported"));
1077 assert!(result.content.contains("career"));
1078 assert!(result.content.contains("inbox"));
1079 assert!(result.content.contains("learn rust"));
1080 }
1081
1082 #[tokio::test]
1083 async fn test_privacy_report() {
1084 let (_dir, workspace) = setup();
1085 let tool = PrivacyManagerTool::new(workspace.clone());
1086
1087 let career_dir = workspace.join(".rustant").join("career");
1089 std::fs::create_dir_all(&career_dir).unwrap();
1090 std::fs::write(career_dir.join("data.json"), "test data content").unwrap();
1091
1092 let result = tool
1093 .execute(json!({"action": "privacy_report"}))
1094 .await
1095 .unwrap();
1096 assert!(result.content.contains("Privacy Report"));
1097 assert!(result.content.contains("Total data size"));
1098 assert!(result.content.contains("career"));
1099 assert!(result.content.contains("Access log"));
1100 assert!(result.content.contains("Unique tools"));
1101 }
1102
1103 #[tokio::test]
1104 async fn test_boundary_type_validation() {
1105 let (_dir, workspace) = setup();
1106 let tool = PrivacyManagerTool::new(workspace);
1107
1108 let result = tool
1109 .execute(json!({
1110 "action": "set_boundary",
1111 "name": "test",
1112 "boundary_type": "invalid_type",
1113 "paths": ["some_path"]
1114 }))
1115 .await
1116 .unwrap();
1117 assert!(result.content.contains("Error"));
1118 assert!(result.content.contains("invalid boundary_type"));
1119 assert!(result.content.contains("invalid_type"));
1120 }
1121
1122 #[tokio::test]
1123 async fn test_access_log_eviction() {
1124 let (_dir, workspace) = setup();
1125 let tool = PrivacyManagerTool::new(workspace);
1126
1127 let mut state = PrivacyState {
1129 max_log_entries: 5,
1130 ..Default::default()
1131 };
1132
1133 for i in 0..7 {
1135 state.access_log.push(AccessLogEntry {
1136 timestamp: Utc::now(),
1137 tool_name: format!("tool_{}", i),
1138 data_accessed: format!("path_{}", i),
1139 purpose: "test".to_string(),
1140 boundary_id: None,
1141 });
1142 if state.access_log.len() > state.max_log_entries {
1144 state
1145 .access_log
1146 .drain(0..state.access_log.len() - state.max_log_entries);
1147 }
1148 }
1149 tool.save_state(&state).unwrap();
1150
1151 let loaded = tool.load_state();
1152 assert_eq!(loaded.access_log.len(), 5);
1153 assert_eq!(loaded.access_log[0].tool_name, "tool_2");
1155 assert_eq!(loaded.access_log[4].tool_name, "tool_6");
1156 }
1157
1158 #[tokio::test]
1159 async fn test_state_roundtrip() {
1160 let (_dir, workspace) = setup();
1161 let tool = PrivacyManagerTool::new(workspace);
1162
1163 let mut state = PrivacyState::default();
1165 state.boundaries.push(DataBoundary {
1166 id: 1,
1167 name: "test_boundary".to_string(),
1168 boundary_type: BoundaryType::Encrypted,
1169 paths: vec!["career".to_string(), "inbox".to_string()],
1170 description: "test description".to_string(),
1171 created_at: Utc::now(),
1172 });
1173 state.access_log.push(AccessLogEntry {
1174 timestamp: Utc::now(),
1175 tool_name: "file_read".to_string(),
1176 data_accessed: "career/goals.json".to_string(),
1177 purpose: "reading goals".to_string(),
1178 boundary_id: Some(1),
1179 });
1180 state.next_id = 2;
1181
1182 tool.save_state(&state).unwrap();
1183 let loaded = tool.load_state();
1184
1185 assert_eq!(loaded.next_id, 2);
1186 assert_eq!(loaded.max_log_entries, 10_000);
1187 assert_eq!(loaded.boundaries.len(), 1);
1188 assert_eq!(loaded.boundaries[0].name, "test_boundary");
1189 assert_eq!(loaded.boundaries[0].boundary_type, BoundaryType::Encrypted);
1190 assert_eq!(loaded.boundaries[0].paths.len(), 2);
1191 assert_eq!(loaded.access_log.len(), 1);
1192 assert_eq!(loaded.access_log[0].tool_name, "file_read");
1193 assert_eq!(loaded.access_log[0].boundary_id, Some(1));
1194 }
1195
1196 #[tokio::test]
1197 async fn test_unknown_action() {
1198 let (_dir, workspace) = setup();
1199 let tool = PrivacyManagerTool::new(workspace);
1200
1201 let result = tool
1202 .execute(json!({"action": "nonexistent"}))
1203 .await
1204 .unwrap();
1205 assert!(result.content.contains("Unknown action"));
1206 assert!(result.content.contains("nonexistent"));
1207 }
1208
1209 #[tokio::test]
1210 async fn test_encrypt_store() {
1211 let (_dir, workspace) = setup();
1212 let tool = PrivacyManagerTool::new(workspace.clone());
1213
1214 let data_dir = workspace.join(".rustant").join("career");
1216 std::fs::create_dir_all(&data_dir).unwrap();
1217 let file_path = data_dir.join("goals.json");
1218 std::fs::write(&file_path, r#"{"goals": ["learn rust"]}"#).unwrap();
1219
1220 let result = tool
1221 .execute(json!({
1222 "action": "encrypt_store",
1223 "path": "career/goals.json"
1224 }))
1225 .await
1226 .unwrap();
1227 assert!(result.content.contains("Encrypted"));
1228 assert!(result.content.contains("base64"));
1229
1230 let encrypted_path = data_dir.join("goals.json.encrypted");
1232 assert!(encrypted_path.exists());
1233 let encrypted_content = std::fs::read_to_string(&encrypted_path).unwrap();
1234 assert!(encrypted_content.contains("base64 placeholder"));
1235 assert!(encrypted_content.contains("AES-256-GCM"));
1236 }
1237
1238 #[tokio::test]
1239 async fn test_set_boundary_missing_name() {
1240 let (_dir, workspace) = setup();
1241 let tool = PrivacyManagerTool::new(workspace);
1242
1243 let result = tool
1244 .execute(json!({
1245 "action": "set_boundary",
1246 "boundary_type": "local_only",
1247 "paths": ["inbox"]
1248 }))
1249 .await
1250 .unwrap();
1251 assert!(result.content.contains("Error"));
1252 assert!(result.content.contains("name"));
1253 }
1254
1255 #[tokio::test]
1256 async fn test_set_boundary_missing_paths() {
1257 let (_dir, workspace) = setup();
1258 let tool = PrivacyManagerTool::new(workspace);
1259
1260 let result = tool
1261 .execute(json!({
1262 "action": "set_boundary",
1263 "name": "test",
1264 "boundary_type": "local_only"
1265 }))
1266 .await
1267 .unwrap();
1268 assert!(result.content.contains("Error"));
1269 assert!(result.content.contains("paths"));
1270 }
1271
1272 #[tokio::test]
1273 async fn test_delete_data_nonexistent_domain() {
1274 let (_dir, workspace) = setup();
1275 let tool = PrivacyManagerTool::new(workspace.clone());
1276 std::fs::create_dir_all(workspace.join(".rustant")).unwrap();
1277
1278 let result = tool
1279 .execute(json!({"action": "delete_data", "domain": "nonexistent"}))
1280 .await
1281 .unwrap();
1282 assert!(result.content.contains("not found"));
1283 }
1284
1285 #[tokio::test]
1286 async fn test_encrypt_store_missing_file() {
1287 let (_dir, workspace) = setup();
1288 let tool = PrivacyManagerTool::new(workspace.clone());
1289 std::fs::create_dir_all(workspace.join(".rustant")).unwrap();
1290
1291 let result = tool
1292 .execute(json!({
1293 "action": "encrypt_store",
1294 "path": "nonexistent/file.json"
1295 }))
1296 .await
1297 .unwrap();
1298 assert!(result.content.contains("Error"));
1299 assert!(result.content.contains("not found"));
1300 }
1301
1302 #[tokio::test]
1303 async fn test_delete_data_all() {
1304 let (_dir, workspace) = setup();
1305 let tool = PrivacyManagerTool::new(workspace.clone());
1306
1307 let career_dir = workspace.join(".rustant").join("career");
1309 std::fs::create_dir_all(&career_dir).unwrap();
1310 std::fs::write(career_dir.join("data.json"), "test").unwrap();
1311
1312 let inbox_dir = workspace.join(".rustant").join("inbox");
1313 std::fs::create_dir_all(&inbox_dir).unwrap();
1314 std::fs::write(inbox_dir.join("items.json"), "items").unwrap();
1315
1316 tool.execute(json!({
1318 "action": "set_boundary",
1319 "name": "test",
1320 "boundary_type": "shareable",
1321 "paths": ["career"]
1322 }))
1323 .await
1324 .unwrap();
1325
1326 let result = tool
1327 .execute(json!({"action": "delete_data", "domain": "all"}))
1328 .await
1329 .unwrap();
1330 assert!(
1331 result
1332 .content
1333 .contains("Deleted all data except privacy config")
1334 );
1335
1336 assert!(!career_dir.exists());
1338 assert!(!inbox_dir.exists());
1339 assert!(workspace.join(".rustant").join("privacy").exists());
1340 }
1341
1342 #[tokio::test]
1343 async fn test_audit_access_with_filters() {
1344 let (_dir, workspace) = setup();
1345 let tool = PrivacyManagerTool::new(workspace);
1346
1347 let mut state = PrivacyState::default();
1349 state.access_log.push(AccessLogEntry {
1350 timestamp: Utc::now(),
1351 tool_name: "file_read".to_string(),
1352 data_accessed: "career/goals.json".to_string(),
1353 purpose: "reading".to_string(),
1354 boundary_id: Some(1),
1355 });
1356 state.access_log.push(AccessLogEntry {
1357 timestamp: Utc::now(),
1358 tool_name: "shell_exec".to_string(),
1359 data_accessed: "inbox/items.json".to_string(),
1360 purpose: "listing".to_string(),
1361 boundary_id: None,
1362 });
1363 state.access_log.push(AccessLogEntry {
1364 timestamp: Utc::now(),
1365 tool_name: "file_read".to_string(),
1366 data_accessed: "inbox/archive.json".to_string(),
1367 purpose: "archiving".to_string(),
1368 boundary_id: Some(2),
1369 });
1370 tool.save_state(&state).unwrap();
1371
1372 let result = tool
1374 .execute(json!({"action": "audit_access", "tool_name": "file_read"}))
1375 .await
1376 .unwrap();
1377 assert!(result.content.contains("file_read"));
1378 assert!(!result.content.contains("shell_exec"));
1379 assert!(result.content.contains("2 entries shown"));
1380
1381 let result = tool
1383 .execute(json!({"action": "audit_access", "boundary_id": 1}))
1384 .await
1385 .unwrap();
1386 assert!(result.content.contains("1 entries shown"));
1387 assert!(result.content.contains("career/goals.json"));
1388
1389 let result = tool
1391 .execute(json!({"action": "audit_access", "limit": 1}))
1392 .await
1393 .unwrap();
1394 assert!(result.content.contains("1 entries shown"));
1395 }
1396}