Skip to main content

hematite/agent/
policy.rs

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    // Check for tokens that resolve to system directories outside the workspace.
26    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("/") // Absolute Linux paths are often sovereign or handled by sanitizer
39}
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    // Internal agent metadata is NOT human documentation.
54    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
64/// Block docs edits for any task unless the user explicitly asked for docs.
65pub(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    // 1. Double-Read Guard: Block reading a file immediately after writing it if no context was used.
188    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    // 2. Grep Persistence: If a search failed once this turn, don't repeat it with identical args.
215    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                    // Check if this result belongs to a previous identical grep call
220                    if let Some(_prev_assistant) = history.iter().rev().find(|prev| {
221                        prev.role == "assistant"
222                            && prev.tool_calls.as_ref().map_or(false, |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}