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 = std::path::Path::new(path);
15    let joined = if candidate.is_absolute() {
16        candidate.to_path_buf()
17    } else {
18        root.join(candidate)
19    };
20    joined.to_string_lossy().replace('\\', "/").to_lowercase()
21}
22
23fn prompt_explicitly_targets_docs(prompt: &str) -> bool {
24    let lower = prompt.to_lowercase();
25    lower.contains("readme")
26        || lower.contains("claude.md")
27        || lower.contains("docs/")
28        || lower.contains("documentation")
29        || lower.contains("contributing.md")
30}
31
32pub(crate) fn is_docs_like_path(path: &str) -> bool {
33    let lower = path.replace('\\', "/").to_lowercase();
34    lower.ends_with(".md")
35        || lower.ends_with(".mdx")
36        || lower.contains("/docs/")
37        || lower.ends_with("/claude")
38}
39
40/// Block docs edits for any task unless the user explicitly asked for docs.
41pub(crate) fn docs_edit_without_explicit_request(prompt: &str, normalized_target: &str) -> bool {
42    is_docs_like_path(normalized_target) && !prompt_explicitly_targets_docs(prompt)
43}
44
45pub(crate) fn tool_path_argument(name: &str, args: &Value) -> Option<String> {
46    match name {
47        "read_file"
48        | "inspect_lines"
49        | "list_files"
50        | "grep_files"
51        | "lsp_get_diagnostics"
52        | "lsp_hover"
53        | "lsp_definitions"
54        | "lsp_references"
55        | "write_file"
56        | "edit_file"
57        | "patch_hunk"
58        | "multi_search_replace" => args
59            .get("path")
60            .and_then(|v| v.as_str())
61            .map(normalize_workspace_path),
62        _ if is_mcp_mutating_tool(name) => args
63            .get("path")
64            .or_else(|| args.get("target"))
65            .or_else(|| args.get("target_path"))
66            .or_else(|| args.get("destination"))
67            .or_else(|| args.get("destination_path"))
68            .or_else(|| args.get("source"))
69            .or_else(|| args.get("source_path"))
70            .or_else(|| args.get("from"))
71            .and_then(|v| v.as_str())
72            .map(normalize_workspace_path),
73        _ => None,
74    }
75}
76
77pub(crate) fn is_mcp_mutating_tool(name: &str) -> bool {
78    let metadata = crate::agent::inference::tool_metadata_for_name(name);
79    metadata.external_surface && metadata.mutates_workspace
80}
81
82pub(crate) fn is_mcp_workspace_read_tool(name: &str) -> bool {
83    let metadata = crate::agent::inference::tool_metadata_for_name(name);
84    metadata.external_surface
85        && !metadata.mutates_workspace
86        && name.starts_with("mcp__filesystem__")
87}
88
89pub(crate) fn action_target_path(name: &str, args: &Value) -> Option<String> {
90    match name {
91        "write_file" | "edit_file" | "patch_hunk" | "multi_search_replace" => args
92            .get("path")
93            .and_then(|v| v.as_str())
94            .map(normalize_workspace_path),
95        _ if is_mcp_mutating_tool(name) => tool_path_argument(name, args),
96        _ => None,
97    }
98}
99
100#[allow(dead_code)]
101pub(crate) fn requires_approval(
102    name: &str,
103    args: &Value,
104    config: &crate::agent::config::HematiteConfig,
105) -> bool {
106    use crate::agent::config::{permission_for_shell, PermissionDecision};
107    use crate::tools::RiskLevel;
108
109    if name.starts_with("mcp__") {
110        return true;
111    }
112
113    if name == "write_file" || name == "edit_file" {
114        if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
115            if is_path_safe(path) {
116                return false;
117            }
118        }
119    }
120
121    if name == "shell" {
122        let cmd = args.get("command").and_then(|v| v.as_str()).unwrap_or("");
123
124        match permission_for_shell(cmd, config) {
125            PermissionDecision::Allow => return false,
126            PermissionDecision::Deny | PermissionDecision::Ask => return true,
127            PermissionDecision::UseRiskClassifier => {}
128        }
129
130        if crate::tools::guard::bash_is_safe(cmd).is_err() {
131            return true;
132        }
133
134        return match crate::tools::guard::classify_bash_risk(cmd) {
135            RiskLevel::High => true,
136            RiskLevel::Moderate => true,
137            RiskLevel::Safe => false,
138        };
139    }
140
141    false
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn mcp_mutation_helper_uses_registry_metadata() {
150        assert!(is_mcp_mutating_tool("mcp__filesystem__write_file"));
151        assert!(is_mcp_mutating_tool("mcp__custom__rename_record"));
152        assert!(!is_mcp_mutating_tool("read_file"));
153        assert!(!is_mcp_mutating_tool("mcp__filesystem__read_file"));
154    }
155
156    #[test]
157    fn mcp_workspace_read_helper_stays_filesystem_scoped_and_non_mutating() {
158        assert!(is_mcp_workspace_read_tool("mcp__filesystem__read_file"));
159        assert!(is_mcp_workspace_read_tool(
160            "mcp__filesystem__list_directory"
161        ));
162        assert!(!is_mcp_workspace_read_tool("mcp__filesystem__write_file"));
163        assert!(!is_mcp_workspace_read_tool("mcp__custom__read_record"));
164        assert!(!is_mcp_workspace_read_tool("grep_files"));
165    }
166
167    #[test]
168    fn tool_path_argument_handles_read_and_write_tools() {
169        let read = serde_json::json!({ "path": "src/ui/tui.rs" });
170        let edit = serde_json::json!({ "path": "src/ui/tui.rs" });
171        let expected = normalize_workspace_path("src/ui/tui.rs");
172        assert_eq!(
173            tool_path_argument("read_file", &read),
174            Some(expected.clone())
175        );
176        assert_eq!(tool_path_argument("edit_file", &edit), Some(expected));
177    }
178}