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) {
140 return AuthorizationDecision::Deny {
141 source: AuthorizationSource::ShellBlacklist,
142 reason: "Action blocked: use inspect_host instead of shell for host-inspection questions.".to_string(),
143 };
144 }
145
146 match permission_for_shell(cmd, config) {
147 PermissionDecision::Allow => {
148 return AuthorizationDecision::Allow {
149 source: AuthorizationSource::ConfigAllow,
150 }
151 }
152 PermissionDecision::Ask => {
153 return AuthorizationDecision::Ask {
154 source: AuthorizationSource::ConfigAsk,
155 reason: "Shell command requires approval by `.hematite/settings.json`."
156 .to_string(),
157 }
158 }
159 PermissionDecision::Deny => {
160 return AuthorizationDecision::Deny {
161 source: AuthorizationSource::ConfigDeny,
162 reason: "Action blocked: shell command denied by `.hematite/settings.json`."
163 .to_string(),
164 }
165 }
166 PermissionDecision::UseRiskClassifier => {}
167 }
168
169 if let Err(e) = crate::tools::guard::bash_is_safe(cmd) {
170 return AuthorizationDecision::Deny {
171 source: AuthorizationSource::ShellBlacklist,
172 reason: format!("Action blocked: {}", e),
173 };
174 }
175
176 return match crate::tools::guard::classify_bash_risk(cmd) {
177 RiskLevel::Safe => AuthorizationDecision::Allow {
178 source: AuthorizationSource::ShellRiskSafe,
179 },
180 RiskLevel::Moderate => AuthorizationDecision::Ask {
181 source: AuthorizationSource::ShellRiskModerate,
182 reason: "Shell command classified as moderate risk and requires approval."
183 .to_string(),
184 },
185 RiskLevel::High => AuthorizationDecision::Ask {
186 source: AuthorizationSource::ShellRiskHigh,
187 reason: "Shell command classified as high risk and requires approval.".to_string(),
188 },
189 };
190 }
191
192 if matches!(
193 name,
194 "run_hematite_maintainer_workflow" | "run_workspace_workflow"
195 ) {
196 return AuthorizationDecision::Ask {
197 source: AuthorizationSource::StructuredWorkflowApproval,
198 reason: structured_workflow_reason(name, args),
199 };
200 }
201
202 AuthorizationDecision::Allow {
203 source: if trust_sensitive_tool(name) {
204 AuthorizationSource::WorkspaceTrusted
205 } else {
206 AuthorizationSource::DefaultToolPolicy
207 },
208 }
209}
210
211fn structured_workflow_reason(name: &str, args: &Value) -> String {
212 if name == "run_workspace_workflow" {
213 return match args.get("workflow").and_then(|v| v.as_str()).unwrap_or("") {
214 "build" | "test" | "lint" | "fix" => {
215 "Workspace workflow execution can build, test, or mutate the current project, so it requires approval."
216 .to_string()
217 }
218 "package_script" | "task" | "just" | "make" | "script_path" | "command" => {
219 "Workspace script execution runs commands from the locked project root and may change files, installs, dev servers, or build artifacts, so it requires approval."
220 .to_string()
221 }
222 _ => {
223 "Structured workspace workflow execution changes local state and requires approval."
224 .to_string()
225 }
226 };
227 }
228
229 match args.get("workflow").and_then(|v| v.as_str()).unwrap_or("") {
230 "clean" => {
231 "Repo cleanup changes build artifacts, local Hematite state, and possibly dist/ outputs, so it requires approval."
232 .to_string()
233 }
234 "package_windows" => {
235 "Windows packaging rebuilds release artifacts and may update the user PATH, so it requires approval."
236 .to_string()
237 }
238 "release" => {
239 "The release workflow can bump versions, commit, tag, push, build installers, and publish crates, so it requires approval."
240 .to_string()
241 }
242 _ => {
243 "Structured Hematite maintainer workflow execution changes local state and requires approval."
244 .to_string()
245 }
246 }
247}
248
249fn is_destructive_tool(name: &str) -> bool {
250 crate::agent::inference::tool_metadata_for_name(name).mutates_workspace
251}
252
253pub(crate) fn is_path_safe(path: &str) -> bool {
254 let p = path.to_lowercase();
255 p.contains(".hematite/")
256 || p.contains(".hematite\\")
257 || p.contains("tmp/")
258 || p.contains("tmp\\")
259}
260
261fn trust_sensitive_tool(name: &str) -> bool {
262 crate::agent::inference::tool_metadata_for_name(name).trust_sensitive
263}