Skip to main content

hematite/agent/
redact_policy.rs

1// Redaction policy — loaded from .hematite/redact_policy.json (workspace)
2// or ~/.hematite/redact_policy.json (global). Workspace overrides global.
3//
4// Controls which inspect_host topics the MCP server will serve, and at what
5// redaction level. An absent policy file means "allow all, Tier 1 regex only".
6//
7// Example policy:
8// {
9//   "blocked_topics": ["user_accounts", "credentials", "audit_policy"],
10//   "allowed_topics": [],
11//   "topic_redaction_level": { "network": "semantic", "hardware": "regex" },
12//   "default_redaction_level": "regex"
13// }
14
15use 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    /// Topics that are hard-blocked — MCP returns an error, never runs the inspection.
23    #[serde(default)]
24    pub blocked_topics: HashSet<String>,
25
26    /// If non-empty, only these topics are allowed (whitelist mode).
27    /// An empty vec means all topics are allowed (subject to blocked_topics).
28    #[serde(default)]
29    pub allowed_topics: Vec<String>,
30
31    /// Per-topic redaction level override.
32    /// Values: "none" | "regex" | "semantic"
33    #[serde(default)]
34    pub topic_redaction_level: HashMap<String, RedactionLevel>,
35
36    /// Fallback level when no per-topic override exists.
37    /// Defaults to "regex" when edge_redact is active, "none" otherwise.
38    #[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    /// Pass through unchanged.
46    None,
47    /// Apply Tier 1 regex patterns only (fast, deterministic).
48    Regex,
49    /// Route through local model semantic summarizer, then Tier 1 as safety net.
50    Semantic,
51}
52
53impl RedactPolicy {
54    /// Check whether a topic is blocked by policy.
55    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        // Whitelist mode: if allowed_topics is set and this topic isn't in it, block it.
61        if !self.allowed_topics.is_empty() {
62            return !self.allowed_topics.iter().any(|a| a.to_lowercase() == t);
63        }
64        false
65    }
66
67    /// Effective redaction level for a topic, given whether --edge-redact was passed.
68    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
84/// Load policy from workspace then global, workspace wins.
85pub fn load_policy() -> RedactPolicy {
86    // Workspace: .hematite/redact_policy.json
87    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    // Global: ~/.hematite/redact_policy.json
97    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}