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, Copy)]
33pub struct ApprovalPreset {
34 pub id: &'static str,
35 pub label: &'static str,
36 pub description: &'static str,
37 pub must_ask_all: bool,
38 pub allow_mutations: bool,
39}
40
41impl ApprovalPreset {
42 pub fn for_mode(mode: PermissionMode) -> Self {
43 match mode {
44 PermissionMode::ReadOnly => Self {
45 id: "read-only",
46 label: "Read Only",
47 description:
48 "Hematite can only read files. All mutations and shell commands are blocked.",
49 must_ask_all: false,
50 allow_mutations: false,
51 },
52 PermissionMode::Developer => Self {
53 id: "developer",
54 label: "Developer",
55 description:
56 "Hematite can read/edit files and run commands. Risky actions require approval.",
57 must_ask_all: false,
58 allow_mutations: true,
59 },
60 PermissionMode::SystemAdmin => Self {
61 id: "power-user",
62 label: "Power User",
63 description: "Hematite has full access. Auto-approves most developer tools.",
64 must_ask_all: false,
65 allow_mutations: true,
66 },
67 }
68 }
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub enum AuthorizationDecision {
73 Allow {
74 source: AuthorizationSource,
75 },
76 Ask {
77 source: AuthorizationSource,
78 reason: String,
79 },
80 Deny {
81 source: AuthorizationSource,
82 reason: String,
83 },
84}
85
86impl AuthorizationDecision {
87 pub fn source(self) -> AuthorizationSource {
88 match self {
89 AuthorizationDecision::Allow { source }
90 | AuthorizationDecision::Ask { source, .. }
91 | AuthorizationDecision::Deny { source, .. } => source,
92 }
93 }
94}
95
96pub fn authorize_tool_call(
97 name: &str,
98 args: &Value,
99 config: &HematiteConfig,
100 yolo_flag: bool,
101) -> AuthorizationDecision {
102 let preset = ApprovalPreset::for_mode(config.mode);
103
104 if preset.id == "power-user" {
105 return AuthorizationDecision::Allow {
106 source: AuthorizationSource::SystemAdminMode,
107 };
108 }
109
110 if !preset.allow_mutations && is_destructive_tool(name) {
111 return AuthorizationDecision::Deny {
112 source: AuthorizationSource::ReadOnlyMode,
113 reason: format!(
114 "Action blocked: tool `{}` involves workspace mutations forbidden by the `{}` security preset.",
115 name, preset.label
116 ),
117 };
118 }
119
120 if yolo_flag {
121 return AuthorizationDecision::Allow {
122 source: AuthorizationSource::YoloMode,
123 };
124 }
125
126 let workspace_root = crate::tools::file_ops::workspace_root();
127 let trust = resolve_workspace_trust(&workspace_root, &config.trust);
128 if trust_sensitive_tool(name) {
129 match trust.policy {
130 WorkspaceTrustPolicy::Denied => {
131 return AuthorizationDecision::Deny {
132 source: AuthorizationSource::WorkspaceDenied,
133 reason: format!(
134 "Action blocked: workspace `{}` is denied by trust policy{}.",
135 trust.workspace_display,
136 trust
137 .matched_root
138 .as_ref()
139 .map(|root| format!(" ({})", root))
140 .unwrap_or_default()
141 ),
142 };
143 }
144 WorkspaceTrustPolicy::RequireApproval => {
145 return AuthorizationDecision::Ask {
146 source: AuthorizationSource::WorkspaceApprovalRequired,
147 reason: format!(
148 "Workspace `{}` is not trust-allowlisted, so `{}` requires approval before Hematite performs destructive or external actions there.",
149 trust.workspace_display, name
150 ),
151 };
152 }
153 WorkspaceTrustPolicy::Trusted => {}
154 }
155 }
156
157 if name.starts_with("mcp__") {
158 return AuthorizationDecision::Ask {
159 source: AuthorizationSource::McpExternal,
160 reason: format!(
161 "External MCP tool `{}` requires explicit operator approval.",
162 name
163 ),
164 };
165 }
166
167 if matches!(name, "write_file" | "edit_file") {
168 if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
169 if is_path_safe(path) {
170 return AuthorizationDecision::Allow {
171 source: AuthorizationSource::SafePathBypass,
172 };
173 }
174 }
175 }
176
177 if name == "shell" {
178 let cmd = args.get("command").and_then(|v| v.as_str()).unwrap_or("");
179
180 if crate::agent::conversation::shell_looks_like_structured_host_inspection(cmd)
184 && crate::agent::routing::preferred_host_inspection_topic(cmd).is_some()
185 {
186 return AuthorizationDecision::Deny {
187 source: AuthorizationSource::ShellBlacklist,
188 reason: "Action blocked: use inspect_host instead of shell for host-inspection questions.".to_string(),
189 };
190 }
191
192 match permission_for_shell(cmd, config) {
193 PermissionDecision::Allow => {
194 return AuthorizationDecision::Allow {
195 source: AuthorizationSource::ConfigAllow,
196 }
197 }
198 PermissionDecision::Ask => {
199 return AuthorizationDecision::Ask {
200 source: AuthorizationSource::ConfigAsk,
201 reason: "Shell command requires approval by `.hematite/settings.json`."
202 .to_string(),
203 }
204 }
205 PermissionDecision::Deny => {
206 return AuthorizationDecision::Deny {
207 source: AuthorizationSource::ConfigDeny,
208 reason: "Action blocked: shell command denied by `.hematite/settings.json`."
209 .to_string(),
210 }
211 }
212 PermissionDecision::UseRiskClassifier => {}
213 }
214
215 if let Err(e) = crate::tools::guard::bash_is_safe(cmd) {
216 return AuthorizationDecision::Deny {
217 source: AuthorizationSource::ShellBlacklist,
218 reason: format!("Action blocked: {}", e),
219 };
220 }
221
222 return match crate::tools::guard::classify_bash_risk(cmd) {
223 RiskLevel::Safe => AuthorizationDecision::Allow {
224 source: AuthorizationSource::ShellRiskSafe,
225 },
226 RiskLevel::Moderate => AuthorizationDecision::Ask {
227 source: AuthorizationSource::ShellRiskModerate,
228 reason: "Shell command classified as moderate risk and requires approval."
229 .to_string(),
230 },
231 RiskLevel::High => AuthorizationDecision::Ask {
232 source: AuthorizationSource::ShellRiskHigh,
233 reason: "Shell command classified as high risk and requires approval.".to_string(),
234 },
235 };
236 }
237
238 if matches!(
239 name,
240 "run_hematite_maintainer_workflow" | "run_workspace_workflow"
241 ) {
242 return AuthorizationDecision::Ask {
243 source: AuthorizationSource::StructuredWorkflowApproval,
244 reason: structured_workflow_reason(name, args),
245 };
246 }
247
248 AuthorizationDecision::Allow {
249 source: if trust_sensitive_tool(name) {
250 AuthorizationSource::WorkspaceTrusted
251 } else {
252 AuthorizationSource::DefaultToolPolicy
253 },
254 }
255}
256
257fn structured_workflow_reason(name: &str, args: &Value) -> String {
258 if name == "run_workspace_workflow" {
259 return match args.get("workflow").and_then(|v| v.as_str()).unwrap_or("") {
260 "build" | "test" | "lint" | "fix" => {
261 "Workspace workflow execution can build, test, or mutate the current project, so it requires approval."
262 .to_string()
263 }
264 "package_script" | "task" | "just" | "make" | "script_path" | "command" => {
265 "Workspace script execution runs commands from the locked project root and may change files, installs, dev servers, or build artifacts, so it requires approval."
266 .to_string()
267 }
268 _ => {
269 "Structured workspace workflow execution changes local state and requires approval."
270 .to_string()
271 }
272 };
273 }
274
275 match args.get("workflow").and_then(|v| v.as_str()).unwrap_or("") {
276 "clean" => {
277 "Repo cleanup changes build artifacts, local Hematite state, and possibly dist/ outputs, so it requires approval."
278 .to_string()
279 }
280 "package_windows" => {
281 "Windows packaging rebuilds release artifacts and may update the user PATH, so it requires approval."
282 .to_string()
283 }
284 "release" => {
285 "The release workflow can bump versions, commit, tag, push, build installers, and publish crates, so it requires approval."
286 .to_string()
287 }
288 _ => {
289 "Structured Hematite maintainer workflow execution changes local state and requires approval."
290 .to_string()
291 }
292 }
293}
294
295fn is_destructive_tool(name: &str) -> bool {
296 crate::agent::inference::tool_metadata_for_name(name).mutates_workspace
297}
298
299pub(crate) fn is_path_safe(path: &str) -> bool {
300 let p = path.to_lowercase();
301 p.contains(".hematite/")
302 || p.contains(".hematite\\")
303 || p.contains("tmp/")
304 || p.contains("tmp\\")
305}
306
307fn trust_sensitive_tool(name: &str) -> bool {
308 crate::agent::inference::tool_metadata_for_name(name).trust_sensitive
309}