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
137        // Auto-deny any shell call that looks like a host-inspection question.
138        // validate_action_preconditions will auto-redirect it to inspect_host.
139        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}