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 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}