hematite/agent/
permission_enforcer.rs1use 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 DefaultToolPolicy,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum AuthorizationDecision {
31 Allow {
32 source: AuthorizationSource,
33 },
34 Ask {
35 source: AuthorizationSource,
36 reason: String,
37 },
38 Deny {
39 source: AuthorizationSource,
40 reason: String,
41 },
42}
43
44impl AuthorizationDecision {
45 pub fn source(self) -> AuthorizationSource {
46 match self {
47 AuthorizationDecision::Allow { source }
48 | AuthorizationDecision::Ask { source, .. }
49 | AuthorizationDecision::Deny { source, .. } => source,
50 }
51 }
52}
53
54pub fn authorize_tool_call(
55 name: &str,
56 args: &Value,
57 config: &HematiteConfig,
58 yolo_flag: bool,
59) -> AuthorizationDecision {
60 if config.mode == PermissionMode::SystemAdmin {
61 return AuthorizationDecision::Allow {
62 source: AuthorizationSource::SystemAdminMode,
63 };
64 }
65
66 if config.mode == PermissionMode::ReadOnly && is_destructive_tool(name) {
67 return AuthorizationDecision::Deny {
68 source: AuthorizationSource::ReadOnlyMode,
69 reason: format!(
70 "Action blocked: tool `{}` is forbidden in permission mode `{:?}`.",
71 name, config.mode
72 ),
73 };
74 }
75
76 if yolo_flag {
77 return AuthorizationDecision::Allow {
78 source: AuthorizationSource::YoloMode,
79 };
80 }
81
82 let workspace_root = crate::tools::file_ops::workspace_root();
83 let trust = resolve_workspace_trust(&workspace_root, &config.trust);
84 if trust_sensitive_tool(name) {
85 match trust.policy {
86 WorkspaceTrustPolicy::Denied => {
87 return AuthorizationDecision::Deny {
88 source: AuthorizationSource::WorkspaceDenied,
89 reason: format!(
90 "Action blocked: workspace `{}` is denied by trust policy{}.",
91 trust.workspace_display,
92 trust
93 .matched_root
94 .as_ref()
95 .map(|root| format!(" ({})", root))
96 .unwrap_or_default()
97 ),
98 };
99 }
100 WorkspaceTrustPolicy::RequireApproval => {
101 return AuthorizationDecision::Ask {
102 source: AuthorizationSource::WorkspaceApprovalRequired,
103 reason: format!(
104 "Workspace `{}` is not trust-allowlisted, so `{}` requires approval before Hematite performs destructive or external actions there.",
105 trust.workspace_display, name
106 ),
107 };
108 }
109 WorkspaceTrustPolicy::Trusted => {}
110 }
111 }
112
113 if name.starts_with("mcp__") {
114 return AuthorizationDecision::Ask {
115 source: AuthorizationSource::McpExternal,
116 reason: format!(
117 "External MCP tool `{}` requires explicit operator approval.",
118 name
119 ),
120 };
121 }
122
123 if matches!(name, "write_file" | "edit_file") {
124 if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
125 if is_path_safe(path) {
126 return AuthorizationDecision::Allow {
127 source: AuthorizationSource::SafePathBypass,
128 };
129 }
130 }
131 }
132
133 if name == "shell" {
134 let cmd = args.get("command").and_then(|v| v.as_str()).unwrap_or("");
135 match permission_for_shell(cmd, config) {
136 PermissionDecision::Allow => {
137 return AuthorizationDecision::Allow {
138 source: AuthorizationSource::ConfigAllow,
139 }
140 }
141 PermissionDecision::Ask => {
142 return AuthorizationDecision::Ask {
143 source: AuthorizationSource::ConfigAsk,
144 reason: "Shell command requires approval by `.hematite/settings.json`."
145 .to_string(),
146 }
147 }
148 PermissionDecision::Deny => {
149 return AuthorizationDecision::Deny {
150 source: AuthorizationSource::ConfigDeny,
151 reason: "Action blocked: shell command denied by `.hematite/settings.json`."
152 .to_string(),
153 }
154 }
155 PermissionDecision::UseRiskClassifier => {}
156 }
157
158 if let Err(e) = crate::tools::guard::bash_is_safe(cmd) {
159 return AuthorizationDecision::Deny {
160 source: AuthorizationSource::ShellBlacklist,
161 reason: format!("Action blocked: {}", e),
162 };
163 }
164
165 return match crate::tools::guard::classify_bash_risk(cmd) {
166 RiskLevel::Safe => AuthorizationDecision::Allow {
167 source: AuthorizationSource::ShellRiskSafe,
168 },
169 RiskLevel::Moderate => AuthorizationDecision::Ask {
170 source: AuthorizationSource::ShellRiskModerate,
171 reason: "Shell command classified as moderate risk and requires approval."
172 .to_string(),
173 },
174 RiskLevel::High => AuthorizationDecision::Ask {
175 source: AuthorizationSource::ShellRiskHigh,
176 reason: "Shell command classified as high risk and requires approval.".to_string(),
177 },
178 };
179 }
180
181 AuthorizationDecision::Allow {
182 source: if trust_sensitive_tool(name) {
183 AuthorizationSource::WorkspaceTrusted
184 } else {
185 AuthorizationSource::DefaultToolPolicy
186 },
187 }
188}
189
190fn is_destructive_tool(name: &str) -> bool {
191 crate::agent::inference::tool_metadata_for_name(name).mutates_workspace
192}
193
194pub(crate) fn is_path_safe(path: &str) -> bool {
195 let p = path.to_lowercase();
196 p.contains(".hematite/")
197 || p.contains(".hematite\\")
198 || p.contains("tmp/")
199 || p.contains("tmp\\")
200}
201
202fn trust_sensitive_tool(name: &str) -> bool {
203 crate::agent::inference::tool_metadata_for_name(name).trust_sensitive
204}