clawdstrike 0.2.5

Security guards and policy engine for AI agent execution
Documentation
use std::collections::HashSet;

use serde::{Deserialize, Serialize};

use crate::error::{Error, Result};
use crate::semver_utils::{is_strict_semver, parse_strict_semver};

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginManifest {
    pub plugin: PluginMetadata,
    #[serde(default)]
    pub clawdstrike: Option<PluginClawdstrikeCompatibility>,
    #[serde(default)]
    pub guards: Vec<PluginGuardManifestEntry>,
    #[serde(default)]
    pub capabilities: PluginCapabilities,
    #[serde(default)]
    pub resources: PluginResourceLimits,
    #[serde(default)]
    pub trust: PluginTrust,
}

impl PluginManifest {
    pub fn validate(&self) -> Result<()> {
        if self.plugin.version.trim().is_empty() {
            return Err(Error::ConfigError(
                "plugin manifest.plugin.version must be a non-empty string".to_string(),
            ));
        }
        if self.plugin.name.trim().is_empty() {
            return Err(Error::ConfigError(
                "plugin manifest.plugin.name must be a non-empty string".to_string(),
            ));
        }
        if self.guards.is_empty() {
            return Err(Error::ConfigError(
                "plugin manifest.guards must be a non-empty array".to_string(),
            ));
        }
        if self.resources.max_memory_mb < 1
            || self.resources.max_cpu_ms < 1
            || self.resources.max_timeout_ms < 1
        {
            return Err(Error::ConfigError(
                "plugin manifest.resources values must be positive integers".to_string(),
            ));
        }

        if let Some(compat) = &self.clawdstrike {
            if let Some(min) = &compat.min_version {
                if !is_strict_semver(min) {
                    return Err(Error::ConfigError(
                        "plugin manifest.clawdstrike.min_version must be strict semver (x.y.z)"
                            .to_string(),
                    ));
                }
            }

            if let Some(max) = &compat.max_version {
                if !is_semver_range(max) {
                    return Err(Error::ConfigError(
                        "plugin manifest.clawdstrike.max_version must be semver or wildcard range (e.g. 1.x)"
                            .to_string(),
                    ));
                }
            }
        }

        let mut names = HashSet::new();
        for (idx, guard) in self.guards.iter().enumerate() {
            if guard.name.trim().is_empty() {
                return Err(Error::ConfigError(format!(
                    "plugin manifest.guards[{idx}].name must be a non-empty string"
                )));
            }
            if !names.insert(guard.name.clone()) {
                return Err(Error::ConfigError(format!(
                    "plugin manifest.guards[{idx}].name duplicates guard: {}",
                    guard.name
                )));
            }
            if let Some(entrypoint) = &guard.entrypoint {
                if entrypoint.trim().is_empty() {
                    return Err(Error::ConfigError(format!(
                        "plugin manifest.guards[{idx}].entrypoint must be a non-empty string when provided"
                    )));
                }
            }
        }

        if self.trust.level == PluginTrustLevel::Untrusted {
            if self.capabilities.subprocess {
                return Err(Error::ConfigError(format!(
                    "untrusted plugin {} cannot request subprocess capability",
                    self.plugin.name
                )));
            }
            if self.capabilities.filesystem.write {
                return Err(Error::ConfigError(format!(
                    "untrusted plugin {} cannot request filesystem write capability",
                    self.plugin.name
                )));
            }
            if self.capabilities.secrets.access {
                return Err(Error::ConfigError(format!(
                    "untrusted plugin {} cannot request secrets access capability",
                    self.plugin.name
                )));
            }
        }

        Ok(())
    }
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginMetadata {
    pub version: String,
    pub name: String,
    #[serde(default)]
    pub display_name: Option<String>,
    #[serde(default)]
    pub description: Option<String>,
    #[serde(default)]
    pub author: Option<String>,
    #[serde(default)]
    pub license: Option<String>,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginClawdstrikeCompatibility {
    #[serde(default)]
    pub min_version: Option<String>,
    #[serde(default)]
    pub max_version: Option<String>,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginGuardManifestEntry {
    pub name: String,
    #[serde(default)]
    pub display_name: Option<String>,
    #[serde(default)]
    pub entrypoint: Option<String>,
    #[serde(default)]
    pub handles: Vec<String>,
}

#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginCapabilities {
    #[serde(default)]
    pub network: bool,
    #[serde(default)]
    pub subprocess: bool,
    #[serde(default)]
    pub filesystem: PluginFilesystemCapabilities,
    #[serde(default)]
    pub secrets: PluginSecretsCapabilities,
}

#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginFilesystemCapabilities {
    #[serde(default)]
    pub read: Vec<String>,
    #[serde(default)]
    pub write: bool,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct PluginSecretsCapabilities {
    #[serde(default)]
    pub access: bool,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PluginTrustLevel {
    Trusted,
    Untrusted,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PluginSandbox {
    Native,
    Wasm,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginTrust {
    pub level: PluginTrustLevel,
    pub sandbox: PluginSandbox,
}

impl Default for PluginTrust {
    fn default() -> Self {
        Self {
            level: PluginTrustLevel::Untrusted,
            sandbox: PluginSandbox::Wasm,
        }
    }
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginResourceLimits {
    #[serde(default = "default_max_memory_mb")]
    pub max_memory_mb: u32,
    #[serde(default = "default_max_cpu_ms")]
    pub max_cpu_ms: u32,
    #[serde(default = "default_max_timeout_ms")]
    pub max_timeout_ms: u32,
}

impl Default for PluginResourceLimits {
    fn default() -> Self {
        Self {
            max_memory_mb: default_max_memory_mb(),
            max_cpu_ms: default_max_cpu_ms(),
            max_timeout_ms: default_max_timeout_ms(),
        }
    }
}

const fn default_max_memory_mb() -> u32 {
    64
}

const fn default_max_cpu_ms() -> u32 {
    100
}

const fn default_max_timeout_ms() -> u32 {
    5000
}

pub fn parse_plugin_manifest_toml(content: &str) -> Result<PluginManifest> {
    let manifest: PluginManifest = toml::from_str(content)
        .map_err(|e| Error::ConfigError(format!("failed to parse plugin manifest TOML: {e}")))?;
    manifest.validate()?;
    Ok(manifest)
}

fn is_semver_range(value: &str) -> bool {
    parse_strict_semver(value).is_some()
        || parse_major_wildcard(value).is_some()
        || parse_minor_wildcard(value).is_some()
}

fn parse_major_wildcard(value: &str) -> Option<u32> {
    let mut parts = value.split('.');
    let major = parts.next()?.parse::<u32>().ok()?;
    let mid = parts.next()?;
    if mid != "x" || parts.next().is_some() {
        return None;
    }
    Some(major)
}

fn parse_minor_wildcard(value: &str) -> Option<(u32, u32)> {
    let mut parts = value.split('.');
    let major = parts.next()?.parse::<u32>().ok()?;
    let minor = parts.next()?.parse::<u32>().ok()?;
    let patch = parts.next()?;
    if patch != "x" || parts.next().is_some() {
        return None;
    }
    Some((major, minor))
}

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

    #[test]
    fn parses_minimal_manifest_and_defaults() {
        let raw = r#"
[plugin]
version = "1.0.0"
name = "acme-guard"

[[guards]]
name = "acme.deny"

[trust]
level = "trusted"
sandbox = "native"
"#;

        let manifest = parse_plugin_manifest_toml(raw).expect("manifest parsed");
        assert_eq!(manifest.plugin.name, "acme-guard");
        assert_eq!(manifest.resources.max_memory_mb, 64);
        assert!(!manifest.capabilities.network);
        assert!(!manifest.capabilities.filesystem.write);
    }

    #[test]
    fn rejects_duplicate_guard_names() {
        let raw = r#"
[plugin]
version = "1.0.0"
name = "acme-guard"

[[guards]]
name = "acme.deny"

[[guards]]
name = "acme.deny"

[trust]
level = "trusted"
sandbox = "native"
"#;

        let err = parse_plugin_manifest_toml(raw).expect_err("duplicate should fail");
        assert!(err.to_string().contains("duplicates guard"));
    }

    #[test]
    fn rejects_untrusted_high_risk_caps() {
        let raw = r#"
[plugin]
version = "1.0.0"
name = "acme-untrusted"

[[guards]]
name = "acme.guard"

[capabilities]
subprocess = true

[trust]
level = "untrusted"
sandbox = "wasm"
"#;

        let err = parse_plugin_manifest_toml(raw).expect_err("should reject subprocess");
        assert!(err
            .to_string()
            .contains("cannot request subprocess capability"));
    }
}