hematite-cli 0.5.4

Local AI coding harness for LM Studio with TUI, voice, retrieval, and grounded workstation tooling
Documentation
use std::path::{Path, PathBuf};

use crate::agent::config::WorkspaceTrustConfig;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WorkspaceTrustPolicy {
    Trusted,
    RequireApproval,
    Denied,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WorkspaceTrustSource {
    DefaultWorkspace,
    Allowlist,
    UnknownWorkspace,
    Denylist,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkspaceTrustDecision {
    pub policy: WorkspaceTrustPolicy,
    pub source: WorkspaceTrustSource,
    pub workspace_display: String,
    pub matched_root: Option<String>,
    pub reason: Option<String>,
}

pub fn resolve_workspace_trust(
    workspace_root: &Path,
    config: &WorkspaceTrustConfig,
) -> WorkspaceTrustDecision {
    let workspace = normalize_path(workspace_root);
    let workspace_display = workspace.to_string_lossy().replace('\\', "/");

    let denied_roots = resolve_roots(workspace_root, &config.deny);
    if let Some(root) = denied_roots
        .iter()
        .find(|root| path_matches(&workspace, root))
    {
        let matched = root.to_string_lossy().replace('\\', "/");
        return WorkspaceTrustDecision {
            policy: WorkspaceTrustPolicy::Denied,
            source: WorkspaceTrustSource::Denylist,
            workspace_display,
            matched_root: Some(matched.clone()),
            reason: Some(format!("workspace matches denied trust root: {}", matched)),
        };
    }

    let allow_roots = resolve_roots(workspace_root, &config.allow);
    if let Some(root) = allow_roots
        .iter()
        .find(|root| path_matches(&workspace, root))
    {
        let matched = root.to_string_lossy().replace('\\', "/");
        let source = if root == &workspace {
            WorkspaceTrustSource::DefaultWorkspace
        } else {
            WorkspaceTrustSource::Allowlist
        };
        return WorkspaceTrustDecision {
            policy: WorkspaceTrustPolicy::Trusted,
            source,
            workspace_display,
            matched_root: Some(matched),
            reason: None,
        };
    }

    WorkspaceTrustDecision {
        policy: WorkspaceTrustPolicy::RequireApproval,
        source: WorkspaceTrustSource::UnknownWorkspace,
        workspace_display,
        matched_root: None,
        reason: Some(
            "workspace is not trust-allowlisted, so destructive or external actions require approval."
                .to_string(),
        ),
    }
}

fn resolve_roots(workspace_root: &Path, configured_roots: &[String]) -> Vec<PathBuf> {
    configured_roots
        .iter()
        .map(|root| {
            let path = Path::new(root);
            if path.is_absolute() {
                normalize_path(path)
            } else {
                normalize_path(&workspace_root.join(path))
            }
        })
        .collect()
}

fn path_matches(candidate: &Path, root: &Path) -> bool {
    candidate == root || candidate.starts_with(root)
}

fn normalize_path(path: &Path) -> PathBuf {
    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}

#[cfg(test)]
mod tests {
    use super::{resolve_workspace_trust, WorkspaceTrustPolicy, WorkspaceTrustSource};
    use crate::agent::config::WorkspaceTrustConfig;
    use std::path::PathBuf;

    #[test]
    fn trusts_current_workspace_by_default() {
        let root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
        let decision = resolve_workspace_trust(&root, &WorkspaceTrustConfig::default());
        assert_eq!(decision.policy, WorkspaceTrustPolicy::Trusted);
        assert_eq!(decision.source, WorkspaceTrustSource::DefaultWorkspace);
    }

    #[test]
    fn denied_root_takes_precedence() {
        let root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
        let config = WorkspaceTrustConfig {
            allow: vec![".".to_string()],
            deny: vec![root.to_string_lossy().to_string()],
        };
        let decision = resolve_workspace_trust(&root, &config);
        assert_eq!(decision.policy, WorkspaceTrustPolicy::Denied);
        assert_eq!(decision.source, WorkspaceTrustSource::Denylist);
    }

    #[test]
    fn unknown_workspace_requires_approval() {
        let root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
        let config = WorkspaceTrustConfig {
            allow: vec!["./somewhere-else".to_string()],
            deny: Vec::new(),
        };
        let decision = resolve_workspace_trust(&root, &config);
        assert_eq!(decision.policy, WorkspaceTrustPolicy::RequireApproval);
        assert_eq!(decision.source, WorkspaceTrustSource::UnknownWorkspace);
    }
}