hematite-cli 0.4.4

Local AI coding harness for LM Studio with TUI, voice, retrieval, and grounded workstation tooling
Documentation
use serde_json::Value;

use crate::agent::config::{
    permission_for_shell, HematiteConfig, PermissionDecision, PermissionMode,
};
use crate::agent::trust_resolver::{resolve_workspace_trust, WorkspaceTrustPolicy};
use crate::tools::RiskLevel;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthorizationSource {
    SystemAdminMode,
    ReadOnlyMode,
    YoloMode,
    WorkspaceTrusted,
    WorkspaceApprovalRequired,
    WorkspaceDenied,
    McpExternal,
    SafePathBypass,
    ConfigAllow,
    ConfigAsk,
    ConfigDeny,
    ShellBlacklist,
    ShellRiskSafe,
    ShellRiskModerate,
    ShellRiskHigh,
    DefaultToolPolicy,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthorizationDecision {
    Allow {
        source: AuthorizationSource,
    },
    Ask {
        source: AuthorizationSource,
        reason: String,
    },
    Deny {
        source: AuthorizationSource,
        reason: String,
    },
}

impl AuthorizationDecision {
    pub fn source(self) -> AuthorizationSource {
        match self {
            AuthorizationDecision::Allow { source }
            | AuthorizationDecision::Ask { source, .. }
            | AuthorizationDecision::Deny { source, .. } => source,
        }
    }
}

pub fn authorize_tool_call(
    name: &str,
    args: &Value,
    config: &HematiteConfig,
    yolo_flag: bool,
) -> AuthorizationDecision {
    if config.mode == PermissionMode::SystemAdmin {
        return AuthorizationDecision::Allow {
            source: AuthorizationSource::SystemAdminMode,
        };
    }

    if config.mode == PermissionMode::ReadOnly && is_destructive_tool(name) {
        return AuthorizationDecision::Deny {
            source: AuthorizationSource::ReadOnlyMode,
            reason: format!(
                "Action blocked: tool `{}` is forbidden in permission mode `{:?}`.",
                name, config.mode
            ),
        };
    }

    if yolo_flag {
        return AuthorizationDecision::Allow {
            source: AuthorizationSource::YoloMode,
        };
    }

    let workspace_root = crate::tools::file_ops::workspace_root();
    let trust = resolve_workspace_trust(&workspace_root, &config.trust);
    if trust_sensitive_tool(name) {
        match trust.policy {
            WorkspaceTrustPolicy::Denied => {
                return AuthorizationDecision::Deny {
                    source: AuthorizationSource::WorkspaceDenied,
                    reason: format!(
                        "Action blocked: workspace `{}` is denied by trust policy{}.",
                        trust.workspace_display,
                        trust
                            .matched_root
                            .as_ref()
                            .map(|root| format!(" ({})", root))
                            .unwrap_or_default()
                    ),
                };
            }
            WorkspaceTrustPolicy::RequireApproval => {
                return AuthorizationDecision::Ask {
                    source: AuthorizationSource::WorkspaceApprovalRequired,
                    reason: format!(
                        "Workspace `{}` is not trust-allowlisted, so `{}` requires approval before Hematite performs destructive or external actions there.",
                        trust.workspace_display, name
                    ),
                };
            }
            WorkspaceTrustPolicy::Trusted => {}
        }
    }

    if name.starts_with("mcp__") {
        return AuthorizationDecision::Ask {
            source: AuthorizationSource::McpExternal,
            reason: format!(
                "External MCP tool `{}` requires explicit operator approval.",
                name
            ),
        };
    }

    if matches!(name, "write_file" | "edit_file") {
        if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
            if is_path_safe(path) {
                return AuthorizationDecision::Allow {
                    source: AuthorizationSource::SafePathBypass,
                };
            }
        }
    }

    if name == "shell" {
        let cmd = args.get("command").and_then(|v| v.as_str()).unwrap_or("");
        match permission_for_shell(cmd, config) {
            PermissionDecision::Allow => {
                return AuthorizationDecision::Allow {
                    source: AuthorizationSource::ConfigAllow,
                }
            }
            PermissionDecision::Ask => {
                return AuthorizationDecision::Ask {
                    source: AuthorizationSource::ConfigAsk,
                    reason: "Shell command requires approval by `.hematite/settings.json`."
                        .to_string(),
                }
            }
            PermissionDecision::Deny => {
                return AuthorizationDecision::Deny {
                    source: AuthorizationSource::ConfigDeny,
                    reason: "Action blocked: shell command denied by `.hematite/settings.json`."
                        .to_string(),
                }
            }
            PermissionDecision::UseRiskClassifier => {}
        }

        if let Err(e) = crate::tools::guard::bash_is_safe(cmd) {
            return AuthorizationDecision::Deny {
                source: AuthorizationSource::ShellBlacklist,
                reason: format!("Action blocked: {}", e),
            };
        }

        return match crate::tools::guard::classify_bash_risk(cmd) {
            RiskLevel::Safe => AuthorizationDecision::Allow {
                source: AuthorizationSource::ShellRiskSafe,
            },
            RiskLevel::Moderate => AuthorizationDecision::Ask {
                source: AuthorizationSource::ShellRiskModerate,
                reason: "Shell command classified as moderate risk and requires approval."
                    .to_string(),
            },
            RiskLevel::High => AuthorizationDecision::Ask {
                source: AuthorizationSource::ShellRiskHigh,
                reason: "Shell command classified as high risk and requires approval.".to_string(),
            },
        };
    }

    AuthorizationDecision::Allow {
        source: if trust_sensitive_tool(name) {
            AuthorizationSource::WorkspaceTrusted
        } else {
            AuthorizationSource::DefaultToolPolicy
        },
    }
}

fn is_destructive_tool(name: &str) -> bool {
    crate::agent::inference::tool_metadata_for_name(name).mutates_workspace
}

pub(crate) fn is_path_safe(path: &str) -> bool {
    let p = path.to_lowercase();
    p.contains(".hematite/")
        || p.contains(".hematite\\")
        || p.contains("tmp/")
        || p.contains("tmp\\")
}

fn trust_sensitive_tool(name: &str) -> bool {
    crate::agent::inference::tool_metadata_for_name(name).trust_sensitive
}