Skip to main content

hematite/agent/
permission_enforcer.rs

1use serde_json::Value;
2
3use crate::agent::config::{
4    permission_for_shell, HematiteConfig, PermissionDecision, PermissionMode,
5};
6use crate::agent::trust_resolver::{resolve_workspace_trust, WorkspaceTrustPolicy};
7use crate::tools::RiskLevel;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum AuthorizationSource {
11    SystemAdminMode,
12    ReadOnlyMode,
13    YoloMode,
14    WorkspaceTrusted,
15    WorkspaceApprovalRequired,
16    WorkspaceDenied,
17    McpExternal,
18    SafePathBypass,
19    ConfigAllow,
20    ConfigAsk,
21    ConfigDeny,
22    ShellBlacklist,
23    ShellRiskSafe,
24    ShellRiskModerate,
25    ShellRiskHigh,
26    StructuredWorkflowApproval,
27    DefaultToolPolicy,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum AuthorizationDecision {
32    Allow {
33        source: AuthorizationSource,
34    },
35    Ask {
36        source: AuthorizationSource,
37        reason: String,
38    },
39    Deny {
40        source: AuthorizationSource,
41        reason: String,
42    },
43}
44
45impl AuthorizationDecision {
46    pub fn source(self) -> AuthorizationSource {
47        match self {
48            AuthorizationDecision::Allow { source }
49            | AuthorizationDecision::Ask { source, .. }
50            | AuthorizationDecision::Deny { source, .. } => source,
51        }
52    }
53}
54
55pub fn authorize_tool_call(
56    name: &str,
57    args: &Value,
58    config: &HematiteConfig,
59    yolo_flag: bool,
60) -> AuthorizationDecision {
61    if config.mode == PermissionMode::SystemAdmin {
62        return AuthorizationDecision::Allow {
63            source: AuthorizationSource::SystemAdminMode,
64        };
65    }
66
67    if config.mode == PermissionMode::ReadOnly && is_destructive_tool(name) {
68        return AuthorizationDecision::Deny {
69            source: AuthorizationSource::ReadOnlyMode,
70            reason: format!(
71                "Action blocked: tool `{}` is forbidden in permission mode `{:?}`.",
72                name, config.mode
73            ),
74        };
75    }
76
77    if yolo_flag {
78        return AuthorizationDecision::Allow {
79            source: AuthorizationSource::YoloMode,
80        };
81    }
82
83    let workspace_root = crate::tools::file_ops::workspace_root();
84    let trust = resolve_workspace_trust(&workspace_root, &config.trust);
85    if trust_sensitive_tool(name) {
86        match trust.policy {
87            WorkspaceTrustPolicy::Denied => {
88                return AuthorizationDecision::Deny {
89                    source: AuthorizationSource::WorkspaceDenied,
90                    reason: format!(
91                        "Action blocked: workspace `{}` is denied by trust policy{}.",
92                        trust.workspace_display,
93                        trust
94                            .matched_root
95                            .as_ref()
96                            .map(|root| format!(" ({})", root))
97                            .unwrap_or_default()
98                    ),
99                };
100            }
101            WorkspaceTrustPolicy::RequireApproval => {
102                return AuthorizationDecision::Ask {
103                    source: AuthorizationSource::WorkspaceApprovalRequired,
104                    reason: format!(
105                        "Workspace `{}` is not trust-allowlisted, so `{}` requires approval before Hematite performs destructive or external actions there.",
106                        trust.workspace_display, name
107                    ),
108                };
109            }
110            WorkspaceTrustPolicy::Trusted => {}
111        }
112    }
113
114    if name.starts_with("mcp__") {
115        return AuthorizationDecision::Ask {
116            source: AuthorizationSource::McpExternal,
117            reason: format!(
118                "External MCP tool `{}` requires explicit operator approval.",
119                name
120            ),
121        };
122    }
123
124    if matches!(name, "write_file" | "edit_file") {
125        if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
126            if is_path_safe(path) {
127                return AuthorizationDecision::Allow {
128                    source: AuthorizationSource::SafePathBypass,
129                };
130            }
131        }
132    }
133
134    if name == "shell" {
135        let cmd = args.get("command").and_then(|v| v.as_str()).unwrap_or("");
136
137        // Auto-deny any shell call that looks like a host-inspection question,
138        // IF we have a native topic to redirect it to.
139        // validate_action_preconditions will auto-redirect it to inspect_host.
140        if crate::agent::conversation::shell_looks_like_structured_host_inspection(cmd)
141            && crate::agent::routing::preferred_host_inspection_topic(cmd).is_some()
142        {
143            return AuthorizationDecision::Deny {
144                source: AuthorizationSource::ShellBlacklist,
145                reason: "Action blocked: use inspect_host instead of shell for host-inspection questions.".to_string(),
146            };
147        }
148
149        match permission_for_shell(cmd, config) {
150            PermissionDecision::Allow => {
151                return AuthorizationDecision::Allow {
152                    source: AuthorizationSource::ConfigAllow,
153                }
154            }
155            PermissionDecision::Ask => {
156                return AuthorizationDecision::Ask {
157                    source: AuthorizationSource::ConfigAsk,
158                    reason: "Shell command requires approval by `.hematite/settings.json`."
159                        .to_string(),
160                }
161            }
162            PermissionDecision::Deny => {
163                return AuthorizationDecision::Deny {
164                    source: AuthorizationSource::ConfigDeny,
165                    reason: "Action blocked: shell command denied by `.hematite/settings.json`."
166                        .to_string(),
167                }
168            }
169            PermissionDecision::UseRiskClassifier => {}
170        }
171
172        if let Err(e) = crate::tools::guard::bash_is_safe(cmd) {
173            return AuthorizationDecision::Deny {
174                source: AuthorizationSource::ShellBlacklist,
175                reason: format!("Action blocked: {}", e),
176            };
177        }
178
179        return match crate::tools::guard::classify_bash_risk(cmd) {
180            RiskLevel::Safe => AuthorizationDecision::Allow {
181                source: AuthorizationSource::ShellRiskSafe,
182            },
183            RiskLevel::Moderate => AuthorizationDecision::Ask {
184                source: AuthorizationSource::ShellRiskModerate,
185                reason: "Shell command classified as moderate risk and requires approval."
186                    .to_string(),
187            },
188            RiskLevel::High => AuthorizationDecision::Ask {
189                source: AuthorizationSource::ShellRiskHigh,
190                reason: "Shell command classified as high risk and requires approval.".to_string(),
191            },
192        };
193    }
194
195    if matches!(
196        name,
197        "run_hematite_maintainer_workflow" | "run_workspace_workflow"
198    ) {
199        return AuthorizationDecision::Ask {
200            source: AuthorizationSource::StructuredWorkflowApproval,
201            reason: structured_workflow_reason(name, args),
202        };
203    }
204
205    AuthorizationDecision::Allow {
206        source: if trust_sensitive_tool(name) {
207            AuthorizationSource::WorkspaceTrusted
208        } else {
209            AuthorizationSource::DefaultToolPolicy
210        },
211    }
212}
213
214fn structured_workflow_reason(name: &str, args: &Value) -> String {
215    if name == "run_workspace_workflow" {
216        return match args.get("workflow").and_then(|v| v.as_str()).unwrap_or("") {
217            "build" | "test" | "lint" | "fix" => {
218                "Workspace workflow execution can build, test, or mutate the current project, so it requires approval."
219                    .to_string()
220            }
221            "package_script" | "task" | "just" | "make" | "script_path" | "command" => {
222                "Workspace script execution runs commands from the locked project root and may change files, installs, dev servers, or build artifacts, so it requires approval."
223                    .to_string()
224            }
225            _ => {
226                "Structured workspace workflow execution changes local state and requires approval."
227                    .to_string()
228            }
229        };
230    }
231
232    match args.get("workflow").and_then(|v| v.as_str()).unwrap_or("") {
233        "clean" => {
234            "Repo cleanup changes build artifacts, local Hematite state, and possibly dist/ outputs, so it requires approval."
235                .to_string()
236        }
237        "package_windows" => {
238            "Windows packaging rebuilds release artifacts and may update the user PATH, so it requires approval."
239                .to_string()
240        }
241        "release" => {
242            "The release workflow can bump versions, commit, tag, push, build installers, and publish crates, so it requires approval."
243                .to_string()
244        }
245        _ => {
246            "Structured Hematite maintainer workflow execution changes local state and requires approval."
247                .to_string()
248        }
249    }
250}
251
252fn is_destructive_tool(name: &str) -> bool {
253    crate::agent::inference::tool_metadata_for_name(name).mutates_workspace
254}
255
256pub(crate) fn is_path_safe(path: &str) -> bool {
257    let p = path.to_lowercase();
258    p.contains(".hematite/")
259        || p.contains(".hematite\\")
260        || p.contains("tmp/")
261        || p.contains("tmp\\")
262}
263
264fn trust_sensitive_tool(name: &str) -> bool {
265    crate::agent::inference::tool_metadata_for_name(name).trust_sensitive
266}