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    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}