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 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("/") }
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 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
63pub(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}