hematite-cli 0.5.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,
    StructuredWorkflowApproval,
    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("");

        // Auto-deny any shell call that looks like a host-inspection question.
        // validate_action_preconditions will auto-redirect it to inspect_host.
        if crate::agent::conversation::shell_looks_like_structured_host_inspection(cmd) {
            return AuthorizationDecision::Deny {
                source: AuthorizationSource::ShellBlacklist,
                reason: "Action blocked: use inspect_host instead of shell for host-inspection questions.".to_string(),
            };
        }

        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(),
            },
        };
    }

    if matches!(
        name,
        "run_hematite_maintainer_workflow" | "run_workspace_workflow"
    ) {
        return AuthorizationDecision::Ask {
            source: AuthorizationSource::StructuredWorkflowApproval,
            reason: structured_workflow_reason(name, args),
        };
    }

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

fn structured_workflow_reason(name: &str, args: &Value) -> String {
    if name == "run_workspace_workflow" {
        return match args.get("workflow").and_then(|v| v.as_str()).unwrap_or("") {
            "build" | "test" | "lint" | "fix" => {
                "Workspace workflow execution can build, test, or mutate the current project, so it requires approval."
                    .to_string()
            }
            "package_script" | "task" | "just" | "make" | "script_path" | "command" => {
                "Workspace script execution runs commands from the locked project root and may change files, installs, dev servers, or build artifacts, so it requires approval."
                    .to_string()
            }
            _ => {
                "Structured workspace workflow execution changes local state and requires approval."
                    .to_string()
            }
        };
    }

    match args.get("workflow").and_then(|v| v.as_str()).unwrap_or("") {
        "clean" => {
            "Repo cleanup changes build artifacts, local Hematite state, and possibly dist/ outputs, so it requires approval."
                .to_string()
        }
        "package_windows" => {
            "Windows packaging rebuilds release artifacts and may update the user PATH, so it requires approval."
                .to_string()
        }
        "release" => {
            "The release workflow can bump versions, commit, tag, push, build installers, and publish crates, so it requires approval."
                .to_string()
        }
        _ => {
            "Structured Hematite maintainer workflow execution changes local state and requires approval."
                .to_string()
        }
    }
}

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
}