1use crate::agent::types::ChatMessage;
2use serde_json::Value;
3
4pub(crate) fn is_destructive_tool(name: &str) -> bool {
5 crate::agent::inference::tool_metadata_for_name(name).mutates_workspace
6}
7
8#[allow(dead_code)]
9pub(crate) fn is_path_safe(path: &str) -> bool {
10 crate::agent::permission_enforcer::is_path_safe(path)
11}
12
13pub(crate) fn normalize_workspace_path(path: &str) -> String {
14 let root = crate::tools::file_ops::workspace_root();
15 let candidate = crate::tools::file_ops::resolve_candidate(path);
16 let joined = if candidate.is_absolute() {
17 candidate
18 } else {
19 root.join(candidate)
20 };
21 joined.to_string_lossy().replace('\\', "/").to_lowercase()
22}
23
24pub(crate) fn is_sovereign_path_request(path: &str) -> bool {
25 let upper = path.to_uppercase();
27 upper.contains("@DESKTOP")
28 || upper.contains("@DOWNLOADS")
29 || upper.contains("@DOCUMENTS")
30 || upper.contains("@PICTURES")
31 || upper.contains("@IMAGES")
32 || upper.contains("@VIDEOS")
33 || upper.contains("@MUSIC")
34 || upper.contains("@HOME")
35 || upper.contains("@TEMP")
36 || upper.contains("@TMP")
37 || path.starts_with('~')
38 || path.starts_with("/") }
40
41fn prompt_explicitly_targets_docs(prompt: &str) -> bool {
42 let lower = prompt.to_lowercase();
43 lower.contains("readme")
44 || lower.contains("claude.md")
45 || lower.contains("docs/")
46 || lower.contains("documentation")
47 || lower.contains("contributing.md")
48}
49
50pub(crate) fn is_docs_like_path(path: &str) -> bool {
51 let lower = path.replace('\\', "/").to_lowercase();
52
53 if lower.contains("/.hematite/") || lower.contains(".hematite/") {
55 return false;
56 }
57
58 lower.ends_with(".md")
59 || lower.ends_with(".mdx")
60 || lower.contains("/docs/")
61 || lower.ends_with("/claude")
62}
63
64pub(crate) fn docs_edit_without_explicit_request(prompt: &str, normalized_target: &str) -> bool {
66 is_docs_like_path(normalized_target) && !prompt_explicitly_targets_docs(prompt)
67}
68
69pub(crate) fn tool_path_argument(name: &str, args: &Value) -> Option<String> {
70 match name {
71 "read_file"
72 | "inspect_lines"
73 | "list_files"
74 | "grep_files"
75 | "lsp_get_diagnostics"
76 | "lsp_hover"
77 | "lsp_definitions"
78 | "lsp_references"
79 | "write_file"
80 | "edit_file"
81 | "patch_hunk"
82 | "multi_search_replace" => args
83 .get("path")
84 .and_then(|v| v.as_str())
85 .map(normalize_workspace_path),
86 _ if is_mcp_mutating_tool(name) => args
87 .get("path")
88 .or_else(|| args.get("target"))
89 .or_else(|| args.get("target_path"))
90 .or_else(|| args.get("destination"))
91 .or_else(|| args.get("destination_path"))
92 .or_else(|| args.get("source"))
93 .or_else(|| args.get("source_path"))
94 .or_else(|| args.get("from"))
95 .and_then(|v| v.as_str())
96 .map(normalize_workspace_path),
97 _ => None,
98 }
99}
100
101pub(crate) fn is_mcp_mutating_tool(name: &str) -> bool {
102 let metadata = crate::agent::inference::tool_metadata_for_name(name);
103 metadata.external_surface && metadata.mutates_workspace
104}
105
106pub(crate) fn is_mcp_workspace_read_tool(name: &str) -> bool {
107 let metadata = crate::agent::inference::tool_metadata_for_name(name);
108 metadata.external_surface
109 && !metadata.mutates_workspace
110 && name.starts_with("mcp__filesystem__")
111}
112
113pub(crate) fn action_target_path(name: &str, args: &Value) -> Option<String> {
114 match name {
115 "read_file"
116 | "inspect_lines"
117 | "write_file"
118 | "edit_file"
119 | "patch_hunk"
120 | "multi_search_replace"
121 | "lsp_get_diagnostics"
122 | "lsp_hover"
123 | "lsp_definitions"
124 | "lsp_references" => args
125 .get("path")
126 .and_then(|v| v.as_str())
127 .map(normalize_workspace_path),
128 _ if is_mcp_mutating_tool(name) => tool_path_argument(name, args),
129 _ => None,
130 }
131}
132
133#[allow(dead_code)]
134pub(crate) fn requires_approval(
135 name: &str,
136 args: &Value,
137 config: &crate::agent::config::HematiteConfig,
138) -> bool {
139 use crate::agent::config::{permission_for_shell, PermissionDecision};
140 use crate::tools::RiskLevel;
141
142 if name.starts_with("mcp__") {
143 return true;
144 }
145
146 if name == "write_file" || name == "edit_file" {
147 if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
148 if is_path_safe(path) {
149 return false;
150 }
151 }
152 }
153
154 if name == "shell" {
155 let cmd = args.get("command").and_then(|v| v.as_str()).unwrap_or("");
156
157 match permission_for_shell(cmd, config) {
158 PermissionDecision::Allow => return false,
159 PermissionDecision::Deny | PermissionDecision::Ask => return true,
160 PermissionDecision::UseRiskClassifier => {}
161 }
162
163 if crate::tools::guard::bash_is_safe(cmd).is_err() {
164 return true;
165 }
166
167 return match crate::tools::guard::classify_bash_risk(cmd) {
168 RiskLevel::High => true,
169 RiskLevel::Moderate => true,
170 RiskLevel::Safe => false,
171 };
172 }
173
174 false
175}
176
177pub(crate) fn find_binary_in_path(name: &str) -> bool {
178 let binary = name.split_whitespace().next().unwrap_or(name);
179 which::which(binary).is_ok()
180}
181
182pub(crate) fn is_redundant_action(
183 name: &str,
184 args: &Value,
185 history: &[ChatMessage],
186) -> Option<String> {
187 if name == "read_file" {
189 if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
190 let normalized = normalize_workspace_path(path);
191 if let Some(last_assistant) = history.iter().rev().find(|m| m.role == "assistant") {
192 if let Some(calls) = &last_assistant.tool_calls {
193 if calls.iter().any(|c| {
194 (c.function.name == "write_file" || c.function.name == "edit_file")
195 && c.function
196 .arguments
197 .get("path")
198 .and_then(|v| v.as_str())
199 .map(normalize_workspace_path)
200 == Some(normalized.clone())
201 }) {
202 return Some(format!(
203 "STRICT: You just wrote to `{}` in your previous step. \
204 Do not read it again immediately. Assume your changes are present. \
205 Proceed with verification or the next file.",
206 path
207 ));
208 }
209 }
210 }
211 }
212 }
213
214 if name == "grep_files" || name == "grep_search" {
216 if let Some(query) = args.get("query").and_then(|v| v.as_str()) {
217 for m in history.iter().rev() {
218 if m.role == "tool" && m.content.as_str().contains("0 matches found") {
219 if let Some(_prev_assistant) = history.iter().rev().find(|prev| {
221 prev.role == "assistant"
222 && prev.tool_calls.as_ref().is_some_and(|calls| {
223 calls.iter().any(|c| {
224 c.id == m.tool_call_id.clone().unwrap_or_default()
225 && (c.function.name == "grep_files"
226 || c.function.name == "grep_search")
227 && c.function
228 .arguments
229 .get("query")
230 .and_then(|v| v.as_str())
231 == Some(query)
232 })
233 })
234 }) {
235 return Some(format!(
236 "STOP. You already searched for `{}` and got 0 matches. \
237 Do not repeat the same search. Try a broader term, \
238 check your spelling, or explore the directory structure instead.",
239 query
240 ));
241 }
242 }
243 }
244 }
245 }
246
247 None
248}
249
250#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)]
251pub struct ToolchainHeartbeat {
252 pub node: Option<String>,
253 pub npm: Option<String>,
254 pub cargo: Option<String>,
255 pub rustc: Option<String>,
256}
257
258impl ToolchainHeartbeat {
259 pub fn capture() -> Self {
260 fn get_version(cmd: &str, args: &[&str]) -> Option<String> {
261 std::process::Command::new(cmd)
262 .args(args)
263 .output()
264 .ok()
265 .and_then(|output| {
266 if output.status.success() {
267 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
268 } else {
269 None
270 }
271 })
272 }
273
274 Self {
275 node: get_version("node", &["--version"]),
276 npm: get_version("npm", &["--version"]),
277 cargo: get_version("cargo", &["--version"]),
278 rustc: get_version("rustc", &["--version"]),
279 }
280 }
281
282 pub fn to_summary(&self) -> String {
283 let mut lines = Vec::new();
284 if let Some(v) = &self.node {
285 lines.push(format!("Node: {}", v));
286 }
287 if let Some(v) = &self.npm {
288 lines.push(format!("NPM: {}", v));
289 }
290 if let Some(v) = &self.cargo {
291 lines.push(format!("Cargo: {}", v));
292 }
293 if let Some(v) = &self.rustc {
294 lines.push(format!("Rustc: {}", v));
295 }
296
297 if lines.is_empty() {
298 "No standard toolchains detected in PATH.".to_string()
299 } else {
300 format!(
301 "[Authoritative Environment Heartbeat]\n{}",
302 lines.join("\n")
303 )
304 }
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311
312 #[test]
313 fn mcp_mutation_helper_uses_registry_metadata() {
314 assert!(is_mcp_mutating_tool("mcp__filesystem__write_file"));
315 assert!(is_mcp_mutating_tool("mcp__custom__rename_record"));
316 assert!(!is_mcp_mutating_tool("read_file"));
317 assert!(!is_mcp_mutating_tool("mcp__filesystem__read_file"));
318 }
319
320 #[test]
321 fn mcp_workspace_read_helper_stays_filesystem_scoped_and_non_mutating() {
322 assert!(is_mcp_workspace_read_tool("mcp__filesystem__read_file"));
323 assert!(is_mcp_workspace_read_tool(
324 "mcp__filesystem__list_directory"
325 ));
326 assert!(!is_mcp_workspace_read_tool("mcp__filesystem__write_file"));
327 assert!(!is_mcp_workspace_read_tool("mcp__custom__read_record"));
328 assert!(!is_mcp_workspace_read_tool("grep_files"));
329 }
330
331 #[test]
332 fn tool_path_argument_handles_read_and_write_tools() {
333 let read = serde_json::json!({ "path": "src/ui/tui.rs" });
334 let edit = serde_json::json!({ "path": "src/ui/tui.rs" });
335 let expected = normalize_workspace_path("src/ui/tui.rs");
336 assert_eq!(
337 tool_path_argument("read_file", &read),
338 Some(expected.clone())
339 );
340 assert_eq!(tool_path_argument("edit_file", &edit), Some(expected));
341 }
342
343 #[test]
344 fn normalize_handles_sovereign_tokens() {
345 let normalized = normalize_workspace_path("@HOME/test");
346 let home = dirs::home_dir().unwrap();
347 let expected = home
348 .join("test")
349 .to_string_lossy()
350 .replace('\\', "/")
351 .to_lowercase();
352 assert_eq!(normalized, expected);
353 }
354}