kernex-agent 0.2.0

CLI dev assistant powered by Kernex runtime
use std::collections::{BTreeSet, HashMap};

use serde::Deserialize;

use super::types::{Permission, TrustLevel};

#[derive(Debug, Clone, Deserialize)]
pub struct SkillOverride {
    pub trust: Option<TrustLevel>,
    #[serde(default)]
    pub deny: Vec<Permission>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct PermissionPolicy {
    #[serde(default)]
    pub default_trust: TrustLevel,
    #[serde(default)]
    pub trusted_sources: Vec<String>,
    #[serde(default)]
    pub blocked_skills: Vec<String>,
    #[serde(default)]
    pub overrides: HashMap<String, SkillOverride>,
}

impl Default for PermissionPolicy {
    fn default() -> Self {
        Self {
            default_trust: TrustLevel::Sandboxed,
            trusted_sources: Vec::new(),
            blocked_skills: Vec::new(),
            overrides: HashMap::new(),
        }
    }
}

impl PermissionPolicy {
    pub fn is_blocked(&self, skill_name: &str) -> bool {
        self.blocked_skills.iter().any(|b| b == skill_name)
    }

    pub fn is_trusted_source(&self, source: &str) -> bool {
        self.trusted_sources
            .iter()
            .any(|prefix| source.starts_with(prefix.as_str()))
    }
}

#[derive(Debug, Clone)]
pub struct ResolvedPermissions {
    pub granted: BTreeSet<Permission>,
    pub denied: BTreeSet<Permission>,
}

pub fn resolve_permissions(
    requested: &BTreeSet<Permission>,
    source: &str,
    policy: &PermissionPolicy,
    skill_name: &str,
) -> ResolvedPermissions {
    let override_entry = policy.overrides.get(skill_name);

    let trust = if let Some(ov) = override_entry {
        if let Some(t) = ov.trust {
            t
        } else if policy.is_trusted_source(source) {
            TrustLevel::Trusted
        } else {
            policy.default_trust
        }
    } else if policy.is_trusted_source(source) {
        TrustLevel::Trusted
    } else {
        policy.default_trust
    };

    let allowed = Permission::for_trust_level(trust);

    let deny_set: BTreeSet<Permission> = override_entry
        .map(|ov| ov.deny.iter().copied().collect())
        .unwrap_or_default();

    let granted: BTreeSet<Permission> = requested
        .intersection(&allowed)
        .copied()
        .filter(|p| !deny_set.contains(p))
        .collect();

    let denied: BTreeSet<Permission> = requested
        .iter()
        .copied()
        .filter(|p| !granted.contains(p))
        .collect();

    ResolvedPermissions { granted, denied }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn policy_default() {
        let policy = PermissionPolicy::default();
        assert_eq!(policy.default_trust, TrustLevel::Sandboxed);
        assert!(policy.trusted_sources.is_empty());
        assert!(policy.blocked_skills.is_empty());
    }

    #[test]
    fn policy_is_blocked() {
        let policy = PermissionPolicy {
            blocked_skills: vec!["bad-skill".to_string()],
            ..Default::default()
        };
        assert!(policy.is_blocked("bad-skill"));
        assert!(!policy.is_blocked("good-skill"));
    }

    #[test]
    fn policy_is_trusted_source() {
        let policy = PermissionPolicy {
            trusted_sources: vec!["anthropics/".to_string(), "vercel-labs/".to_string()],
            ..Default::default()
        };
        assert!(policy.is_trusted_source("anthropics/skills"));
        assert!(policy.is_trusted_source("vercel-labs/agent-skills"));
        assert!(!policy.is_trusted_source("random/repo"));
    }

    #[test]
    fn resolve_sandboxed_grants_minimal() {
        let policy = PermissionPolicy::default();
        let mut requested = BTreeSet::new();
        requested.insert(Permission::ContextFiles);
        requested.insert(Permission::SuggestEdits);
        requested.insert(Permission::SuggestCommands);

        let resolved = resolve_permissions(&requested, "random/skill", &policy, "test-skill");

        assert!(resolved.granted.contains(&Permission::ContextFiles));
        assert!(!resolved.granted.contains(&Permission::SuggestEdits));
        assert!(resolved.denied.contains(&Permission::SuggestEdits));
        assert!(resolved.denied.contains(&Permission::SuggestCommands));
    }

    #[test]
    fn resolve_trusted_source_grants_all() {
        let policy = PermissionPolicy {
            trusted_sources: vec!["anthropics/".to_string()],
            ..Default::default()
        };
        let mut requested = BTreeSet::new();
        requested.insert(Permission::ContextFiles);
        requested.insert(Permission::SuggestCommands);

        let resolved = resolve_permissions(&requested, "anthropics/skills", &policy, "test-skill");

        assert!(resolved.granted.contains(&Permission::ContextFiles));
        assert!(resolved.granted.contains(&Permission::SuggestCommands));
        assert!(resolved.denied.is_empty());
    }

    #[test]
    fn resolve_override_trust() {
        let mut overrides = HashMap::new();
        overrides.insert(
            "special-skill".to_string(),
            SkillOverride {
                trust: Some(TrustLevel::Trusted),
                deny: vec![],
            },
        );
        let policy = PermissionPolicy {
            overrides,
            ..Default::default()
        };
        let mut requested = BTreeSet::new();
        requested.insert(Permission::SuggestCommands);

        let resolved = resolve_permissions(&requested, "random/repo", &policy, "special-skill");

        assert!(resolved.granted.contains(&Permission::SuggestCommands));
    }

    #[test]
    fn resolve_override_deny_specific() {
        let mut overrides = HashMap::new();
        overrides.insert(
            "restricted-skill".to_string(),
            SkillOverride {
                trust: Some(TrustLevel::Trusted),
                deny: vec![Permission::SuggestCommands],
            },
        );
        let policy = PermissionPolicy {
            overrides,
            ..Default::default()
        };
        let mut requested = BTreeSet::new();
        requested.insert(Permission::SuggestEdits);
        requested.insert(Permission::SuggestCommands);

        let resolved = resolve_permissions(&requested, "random/repo", &policy, "restricted-skill");

        assert!(resolved.granted.contains(&Permission::SuggestEdits));
        assert!(!resolved.granted.contains(&Permission::SuggestCommands));
        assert!(resolved.denied.contains(&Permission::SuggestCommands));
    }

    #[test]
    fn resolved_contains_permission() {
        let mut granted = BTreeSet::new();
        granted.insert(Permission::ContextFiles);
        let resolved = ResolvedPermissions {
            granted,
            denied: BTreeSet::new(),
        };
        assert!(resolved.granted.contains(&Permission::ContextFiles));
        assert!(!resolved.granted.contains(&Permission::SuggestEdits));
    }
}