Skip to main content

hematite/agent/
policy.rs

1use serde_json::Value;
2
3pub(crate) fn is_destructive_tool(name: &str) -> bool {
4    crate::agent::inference::tool_metadata_for_name(name).mutates_workspace
5}
6
7#[allow(dead_code)]
8pub(crate) fn is_path_safe(path: &str) -> bool {
9    crate::agent::permission_enforcer::is_path_safe(path)
10}
11
12pub(crate) fn normalize_workspace_path(path: &str) -> String {
13    let root = crate::tools::file_ops::workspace_root();
14    let candidate = crate::tools::file_ops::resolve_candidate(path);
15    let joined = if candidate.is_absolute() {
16        candidate
17    } else {
18        root.join(candidate)
19    };
20    joined.to_string_lossy().replace('\\', "/").to_lowercase()
21}
22
23pub(crate) fn is_sovereign_path_request(path: &str) -> bool {
24    // Check for tokens that resolve to system directories outside the workspace.
25    let upper = path.to_uppercase();
26    upper.contains("@DESKTOP")
27        || upper.contains("@DOWNLOADS")
28        || upper.contains("@DOCUMENTS")
29        || upper.contains("@PICTURES")
30        || upper.contains("@IMAGES")
31        || upper.contains("@VIDEOS")
32        || upper.contains("@MUSIC")
33        || upper.contains("@HOME")
34        || upper.contains("@TEMP")
35        || upper.contains("@TMP")
36        || path.starts_with('~')
37        || path.starts_with("/") // Absolute Linux paths are often sovereign or handled by sanitizer
38}
39
40fn prompt_explicitly_targets_docs(prompt: &str) -> bool {
41    let lower = prompt.to_lowercase();
42    lower.contains("readme")
43        || lower.contains("claude.md")
44        || lower.contains("docs/")
45        || lower.contains("documentation")
46        || lower.contains("contributing.md")
47}
48
49pub(crate) fn is_docs_like_path(path: &str) -> bool {
50    let lower = path.replace('\\', "/").to_lowercase();
51
52    // Internal agent metadata is NOT human documentation.
53    if lower.contains("/.hematite/") || lower.contains(".hematite/") {
54        return false;
55    }
56
57    lower.ends_with(".md")
58        || lower.ends_with(".mdx")
59        || lower.contains("/docs/")
60        || lower.ends_with("/claude")
61}
62
63/// Block docs edits for any task unless the user explicitly asked for docs.
64pub(crate) fn docs_edit_without_explicit_request(prompt: &str, normalized_target: &str) -> bool {
65    is_docs_like_path(normalized_target) && !prompt_explicitly_targets_docs(prompt)
66}
67
68pub(crate) fn tool_path_argument(name: &str, args: &Value) -> Option<String> {
69    match name {
70        "read_file"
71        | "inspect_lines"
72        | "list_files"
73        | "grep_files"
74        | "lsp_get_diagnostics"
75        | "lsp_hover"
76        | "lsp_definitions"
77        | "lsp_references"
78        | "write_file"
79        | "edit_file"
80        | "patch_hunk"
81        | "multi_search_replace" => args
82            .get("path")
83            .and_then(|v| v.as_str())
84            .map(normalize_workspace_path),
85        _ if is_mcp_mutating_tool(name) => args
86            .get("path")
87            .or_else(|| args.get("target"))
88            .or_else(|| args.get("target_path"))
89            .or_else(|| args.get("destination"))
90            .or_else(|| args.get("destination_path"))
91            .or_else(|| args.get("source"))
92            .or_else(|| args.get("source_path"))
93            .or_else(|| args.get("from"))
94            .and_then(|v| v.as_str())
95            .map(normalize_workspace_path),
96        _ => None,
97    }
98}
99
100pub(crate) fn is_mcp_mutating_tool(name: &str) -> bool {
101    let metadata = crate::agent::inference::tool_metadata_for_name(name);
102    metadata.external_surface && metadata.mutates_workspace
103}
104
105pub(crate) fn is_mcp_workspace_read_tool(name: &str) -> bool {
106    let metadata = crate::agent::inference::tool_metadata_for_name(name);
107    metadata.external_surface
108        && !metadata.mutates_workspace
109        && name.starts_with("mcp__filesystem__")
110}
111
112pub(crate) fn action_target_path(name: &str, args: &Value) -> Option<String> {
113    match name {
114        "read_file"
115        | "inspect_lines"
116        | "write_file"
117        | "edit_file"
118        | "patch_hunk"
119        | "multi_search_replace"
120        | "lsp_get_diagnostics"
121        | "lsp_hover"
122        | "lsp_definitions"
123        | "lsp_references" => args
124            .get("path")
125            .and_then(|v| v.as_str())
126            .map(normalize_workspace_path),
127        _ if is_mcp_mutating_tool(name) => tool_path_argument(name, args),
128        _ => None,
129    }
130}
131
132#[allow(dead_code)]
133pub(crate) fn requires_approval(
134    name: &str,
135    args: &Value,
136    config: &crate::agent::config::HematiteConfig,
137) -> bool {
138    use crate::agent::config::{permission_for_shell, PermissionDecision};
139    use crate::tools::RiskLevel;
140
141    if name.starts_with("mcp__") {
142        return true;
143    }
144
145    if name == "write_file" || name == "edit_file" {
146        if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
147            if is_path_safe(path) {
148                return false;
149            }
150        }
151    }
152
153    if name == "shell" {
154        let cmd = args.get("command").and_then(|v| v.as_str()).unwrap_or("");
155
156        match permission_for_shell(cmd, config) {
157            PermissionDecision::Allow => return false,
158            PermissionDecision::Deny | PermissionDecision::Ask => return true,
159            PermissionDecision::UseRiskClassifier => {}
160        }
161
162        if crate::tools::guard::bash_is_safe(cmd).is_err() {
163            return true;
164        }
165
166        return match crate::tools::guard::classify_bash_risk(cmd) {
167            RiskLevel::High => true,
168            RiskLevel::Moderate => true,
169            RiskLevel::Safe => false,
170        };
171    }
172
173    false
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn mcp_mutation_helper_uses_registry_metadata() {
182        assert!(is_mcp_mutating_tool("mcp__filesystem__write_file"));
183        assert!(is_mcp_mutating_tool("mcp__custom__rename_record"));
184        assert!(!is_mcp_mutating_tool("read_file"));
185        assert!(!is_mcp_mutating_tool("mcp__filesystem__read_file"));
186    }
187
188    #[test]
189    fn mcp_workspace_read_helper_stays_filesystem_scoped_and_non_mutating() {
190        assert!(is_mcp_workspace_read_tool("mcp__filesystem__read_file"));
191        assert!(is_mcp_workspace_read_tool(
192            "mcp__filesystem__list_directory"
193        ));
194        assert!(!is_mcp_workspace_read_tool("mcp__filesystem__write_file"));
195        assert!(!is_mcp_workspace_read_tool("mcp__custom__read_record"));
196        assert!(!is_mcp_workspace_read_tool("grep_files"));
197    }
198
199    #[test]
200    fn tool_path_argument_handles_read_and_write_tools() {
201        let read = serde_json::json!({ "path": "src/ui/tui.rs" });
202        let edit = serde_json::json!({ "path": "src/ui/tui.rs" });
203        let expected = normalize_workspace_path("src/ui/tui.rs");
204        assert_eq!(
205            tool_path_argument("read_file", &read),
206            Some(expected.clone())
207        );
208        assert_eq!(tool_path_argument("edit_file", &edit), Some(expected));
209    }
210
211    #[test]
212    fn normalize_handles_sovereign_tokens() {
213        let normalized = normalize_workspace_path("@HOME/test");
214        let home = dirs::home_dir().unwrap();
215        let expected = home
216            .join("test")
217            .to_string_lossy()
218            .replace('\\', "/")
219            .to_lowercase();
220        assert_eq!(normalized, expected);
221    }
222}