hematite/agent/
redact_policy.rs1use serde::Deserialize;
16use std::collections::{HashMap, HashSet};
17use std::path::{Path, PathBuf};
18
19#[derive(Debug, Clone, Deserialize, Default)]
20#[serde(rename_all = "snake_case")]
21pub struct RedactPolicy {
22 #[serde(default)]
24 pub blocked_topics: HashSet<String>,
25
26 #[serde(default)]
29 pub allowed_topics: Vec<String>,
30
31 #[serde(default)]
34 pub topic_redaction_level: HashMap<String, RedactionLevel>,
35
36 #[serde(default)]
39 pub default_redaction_level: Option<RedactionLevel>,
40}
41
42#[derive(Debug, Clone, Deserialize, PartialEq)]
43#[serde(rename_all = "snake_case")]
44pub enum RedactionLevel {
45 None,
47 Regex,
49 Semantic,
51}
52
53impl RedactPolicy {
54 pub fn is_blocked(&self, topic: &str) -> bool {
56 let t = topic.to_lowercase();
57 if self.blocked_topics.contains(&t) {
58 return true;
59 }
60 if !self.allowed_topics.is_empty() {
62 return !self.allowed_topics.iter().any(|a| a.to_lowercase() == t);
63 }
64 false
65 }
66
67 pub fn redaction_level(&self, topic: &str, edge_redact_active: bool) -> RedactionLevel {
69 let t = topic.to_lowercase();
70 if let Some(level) = self.topic_redaction_level.get(&t) {
71 return level.clone();
72 }
73 if let Some(ref default) = self.default_redaction_level {
74 return default.clone();
75 }
76 if edge_redact_active {
77 RedactionLevel::Regex
78 } else {
79 RedactionLevel::None
80 }
81 }
82}
83
84pub fn load_policy() -> RedactPolicy {
86 let workspace_path = Path::new(".hematite").join("redact_policy.json");
88 if let Some(policy) = try_load(&workspace_path) {
89 eprintln!(
90 "[hematite mcp] loaded redact policy from {}",
91 workspace_path.display()
92 );
93 return policy;
94 }
95
96 if let Some(home) = home_dir() {
98 let global_path = home.join(".hematite").join("redact_policy.json");
99 if let Some(policy) = try_load(&global_path) {
100 eprintln!(
101 "[hematite mcp] loaded redact policy from {}",
102 global_path.display()
103 );
104 return policy;
105 }
106 }
107
108 RedactPolicy::default()
109}
110
111fn try_load(path: &Path) -> Option<RedactPolicy> {
112 let text = std::fs::read_to_string(path).ok()?;
113 match serde_json::from_str::<RedactPolicy>(&text) {
114 Ok(p) => Some(p),
115 Err(e) => {
116 eprintln!(
117 "[hematite mcp] redact_policy parse error at {}: {e}",
118 path.display()
119 );
120 None
121 }
122 }
123}
124
125fn home_dir() -> Option<PathBuf> {
126 std::env::var_os("USERPROFILE")
127 .or_else(|| std::env::var_os("HOME"))
128 .map(PathBuf::from)
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 fn policy_with_blocked(topics: &[&str]) -> RedactPolicy {
136 RedactPolicy {
137 blocked_topics: topics.iter().map(|s| s.to_string()).collect(),
138 ..Default::default()
139 }
140 }
141
142 #[test]
143 fn blocks_exact_topic() {
144 let p = policy_with_blocked(&["user_accounts", "credentials"]);
145 assert!(p.is_blocked("user_accounts"));
146 assert!(p.is_blocked("credentials"));
147 assert!(!p.is_blocked("network"));
148 }
149
150 #[test]
151 fn block_check_is_case_insensitive() {
152 let p = policy_with_blocked(&["user_accounts"]);
153 assert!(p.is_blocked("User_Accounts"));
154 assert!(p.is_blocked("USER_ACCOUNTS"));
155 }
156
157 #[test]
158 fn whitelist_mode_blocks_unlisted_topics() {
159 let p = RedactPolicy {
160 allowed_topics: vec!["network".into(), "storage".into()],
161 ..Default::default()
162 };
163 assert!(!p.is_blocked("network"));
164 assert!(!p.is_blocked("storage"));
165 assert!(p.is_blocked("user_accounts"));
166 assert!(p.is_blocked("credentials"));
167 }
168
169 #[test]
170 fn default_redaction_level_follows_edge_redact_flag() {
171 let p = RedactPolicy::default();
172 assert_eq!(p.redaction_level("network", true), RedactionLevel::Regex);
173 assert_eq!(p.redaction_level("network", false), RedactionLevel::None);
174 }
175
176 #[test]
177 fn per_topic_override_takes_precedence() {
178 let mut p = RedactPolicy::default();
179 p.topic_redaction_level
180 .insert("network".into(), RedactionLevel::Semantic);
181 assert_eq!(
182 p.redaction_level("network", false),
183 RedactionLevel::Semantic
184 );
185 assert_eq!(p.redaction_level("storage", true), RedactionLevel::Regex);
186 }
187}