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