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/// A structured security profile pairing an approval policy with a sandbox policy.
31/// Ports the Principled Security patterns from Codex-RS.
32#[derive(Debug, Clone, Copy)]
33pub struct ApprovalPreset {
34    pub id: &'static str,
35    pub label: &'static str,
36    pub description: &'static str,
37    pub must_ask_all: bool,
38    pub allow_mutations: bool,
39}
40
41impl ApprovalPreset {
42    pub fn for_mode(mode: PermissionMode) -> Self {
43        match mode {
44            PermissionMode::ReadOnly => Self {
45                id: "read-only",
46                label: "Read Only",
47                description:
48                    "Hematite can only read files. All mutations and shell commands are blocked.",
49                must_ask_all: false,
50                allow_mutations: false,
51            },
52            PermissionMode::Developer => Self {
53                id: "developer",
54                label: "Developer",
55                description:
56                    "Hematite can read/edit files and run commands. Risky actions require approval.",
57                must_ask_all: false,
58                allow_mutations: true,
59            },
60            PermissionMode::SystemAdmin => Self {
61                id: "power-user",
62                label: "Power User",
63                description: "Hematite has full access. Auto-approves most developer tools.",
64                must_ask_all: false,
65                allow_mutations: true,
66            },
67        }
68    }
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub enum AuthorizationDecision {
73    Allow {
74        source: AuthorizationSource,
75    },
76    Ask {
77        source: AuthorizationSource,
78        reason: String,
79    },
80    Deny {
81        source: AuthorizationSource,
82        reason: String,
83    },
84}
85
86impl AuthorizationDecision {
87    pub fn source(self) -> AuthorizationSource {
88        match self {
89            AuthorizationDecision::Allow { source }
90            | AuthorizationDecision::Ask { source, .. }
91            | AuthorizationDecision::Deny { source, .. } => source,
92        }
93    }
94}
95
96pub fn authorize_tool_call(
97    name: &str,
98    args: &Value,
99    config: &HematiteConfig,
100    yolo_flag: bool,
101) -> AuthorizationDecision {
102    let preset = ApprovalPreset::for_mode(config.mode);
103
104    if preset.id == "power-user" {
105        return AuthorizationDecision::Allow {
106            source: AuthorizationSource::SystemAdminMode,
107        };
108    }
109
110    if !preset.allow_mutations && is_destructive_tool(name) {
111        return AuthorizationDecision::Deny {
112            source: AuthorizationSource::ReadOnlyMode,
113            reason: format!(
114                "Action blocked: tool `{}` involves workspace mutations forbidden by the `{}` security preset.",
115                name, preset.label
116            ),
117        };
118    }
119
120    if yolo_flag {
121        return AuthorizationDecision::Allow {
122            source: AuthorizationSource::YoloMode,
123        };
124    }
125
126    let workspace_root = crate::tools::file_ops::workspace_root();
127    let trust = resolve_workspace_trust(&workspace_root, &config.trust);
128    if trust_sensitive_tool(name) {
129        match trust.policy {
130            WorkspaceTrustPolicy::Denied => {
131                return AuthorizationDecision::Deny {
132                    source: AuthorizationSource::WorkspaceDenied,
133                    reason: format!(
134                        "Action blocked: workspace `{}` is denied by trust policy{}.",
135                        trust.workspace_display,
136                        trust
137                            .matched_root
138                            .as_ref()
139                            .map(|root| format!(" ({})", root))
140                            .unwrap_or_default()
141                    ),
142                };
143            }
144            WorkspaceTrustPolicy::RequireApproval => {
145                return AuthorizationDecision::Ask {
146                    source: AuthorizationSource::WorkspaceApprovalRequired,
147                    reason: format!(
148                        "Workspace `{}` is not trust-allowlisted, so `{}` requires approval before Hematite performs destructive or external actions there.",
149                        trust.workspace_display, name
150                    ),
151                };
152            }
153            WorkspaceTrustPolicy::Trusted => {}
154        }
155    }
156
157    if name.starts_with("mcp__") {
158        return AuthorizationDecision::Ask {
159            source: AuthorizationSource::McpExternal,
160            reason: format!(
161                "External MCP tool `{}` requires explicit operator approval.",
162                name
163            ),
164        };
165    }
166
167    if matches!(name, "write_file" | "edit_file") {
168        if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
169            if is_path_safe(path) {
170                return AuthorizationDecision::Allow {
171                    source: AuthorizationSource::SafePathBypass,
172                };
173            }
174        }
175    }
176
177    if name == "shell" {
178        let cmd = args.get("command").and_then(|v| v.as_str()).unwrap_or("");
179
180        // Auto-deny any shell call that looks like a host-inspection question,
181        // IF we have a native topic to redirect it to.
182        // validate_action_preconditions will auto-redirect it to inspect_host.
183        if crate::agent::conversation::shell_looks_like_structured_host_inspection(cmd)
184            && crate::agent::routing::preferred_host_inspection_topic(cmd).is_some()
185        {
186            return AuthorizationDecision::Deny {
187                source: AuthorizationSource::ShellBlacklist,
188                reason: "Action blocked: use inspect_host instead of shell for host-inspection questions.".to_string(),
189            };
190        }
191
192        match permission_for_shell(cmd, config) {
193            PermissionDecision::Allow => {
194                return AuthorizationDecision::Allow {
195                    source: AuthorizationSource::ConfigAllow,
196                }
197            }
198            PermissionDecision::Ask => {
199                return AuthorizationDecision::Ask {
200                    source: AuthorizationSource::ConfigAsk,
201                    reason: "Shell command requires approval by `.hematite/settings.json`."
202                        .to_string(),
203                }
204            }
205            PermissionDecision::Deny => {
206                return AuthorizationDecision::Deny {
207                    source: AuthorizationSource::ConfigDeny,
208                    reason: "Action blocked: shell command denied by `.hematite/settings.json`."
209                        .to_string(),
210                }
211            }
212            PermissionDecision::UseRiskClassifier => {}
213        }
214
215        if let Err(e) = crate::tools::guard::bash_is_safe(cmd) {
216            return AuthorizationDecision::Deny {
217                source: AuthorizationSource::ShellBlacklist,
218                reason: format!("Action blocked: {}", e),
219            };
220        }
221
222        return match crate::tools::guard::classify_bash_risk(cmd) {
223            RiskLevel::Safe => AuthorizationDecision::Allow {
224                source: AuthorizationSource::ShellRiskSafe,
225            },
226            RiskLevel::Moderate => AuthorizationDecision::Ask {
227                source: AuthorizationSource::ShellRiskModerate,
228                reason: "Shell command classified as moderate risk and requires approval."
229                    .to_string(),
230            },
231            RiskLevel::High => AuthorizationDecision::Ask {
232                source: AuthorizationSource::ShellRiskHigh,
233                reason: "Shell command classified as high risk and requires approval.".to_string(),
234            },
235        };
236    }
237
238    if matches!(
239        name,
240        "run_hematite_maintainer_workflow" | "run_workspace_workflow"
241    ) {
242        return AuthorizationDecision::Ask {
243            source: AuthorizationSource::StructuredWorkflowApproval,
244            reason: structured_workflow_reason(name, args),
245        };
246    }
247
248    AuthorizationDecision::Allow {
249        source: if trust_sensitive_tool(name) {
250            AuthorizationSource::WorkspaceTrusted
251        } else {
252            AuthorizationSource::DefaultToolPolicy
253        },
254    }
255}
256
257fn structured_workflow_reason(name: &str, args: &Value) -> String {
258    if name == "run_workspace_workflow" {
259        return match args.get("workflow").and_then(|v| v.as_str()).unwrap_or("") {
260            "build" | "test" | "lint" | "fix" => {
261                "Workspace workflow execution can build, test, or mutate the current project, so it requires approval."
262                    .to_string()
263            }
264            "package_script" | "task" | "just" | "make" | "script_path" | "command" => {
265                "Workspace script execution runs commands from the locked project root and may change files, installs, dev servers, or build artifacts, so it requires approval."
266                    .to_string()
267            }
268            _ => {
269                "Structured workspace workflow execution changes local state and requires approval."
270                    .to_string()
271            }
272        };
273    }
274
275    match args.get("workflow").and_then(|v| v.as_str()).unwrap_or("") {
276        "clean" => {
277            "Repo cleanup changes build artifacts, local Hematite state, and possibly dist/ outputs, so it requires approval."
278                .to_string()
279        }
280        "package_windows" => {
281            "Windows packaging rebuilds release artifacts and may update the user PATH, so it requires approval."
282                .to_string()
283        }
284        "release" => {
285            "The release workflow can bump versions, commit, tag, push, build installers, and publish crates, so it requires approval."
286                .to_string()
287        }
288        _ => {
289            "Structured Hematite maintainer workflow execution changes local state and requires approval."
290                .to_string()
291        }
292    }
293}
294
295fn is_destructive_tool(name: &str) -> bool {
296    crate::agent::inference::tool_metadata_for_name(name).mutates_workspace
297}
298
299pub(crate) fn is_path_safe(path: &str) -> bool {
300    let p = path.to_lowercase();
301    p.contains(".hematite/")
302        || p.contains(".hematite\\")
303        || p.contains("tmp/")
304        || p.contains("tmp\\")
305}
306
307fn trust_sensitive_tool(name: &str) -> bool {
308    crate::agent::inference::tool_metadata_for_name(name).trust_sensitive
309}