Skip to main content

hematite/agent/
permission_enforcer.rs

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}