progit-plugin-sdk 0.3.0

Plugin SDK for ProGit — sandboxed LuaJIT runtime with capability-based security. LSL-1.0 (file-level copyleft, proprietary plugins allowed via the commercial bridge).
Documentation
// SPDX-License-Identifier: LSL-1.0
// Copyright (c) 2025 Markus Maiwald

//! Plugin manifest (`.progit-plugin.json`) — typed mirror.
//!
//! [ARCH] Plugins ship a manifest next to their entry point. The manifest
//! is the *trusted declaration* — the Lua/Wasm code must match it. The host
//! cross-checks at load time. If the Lua side declares hooks that aren't
//! in the manifest, that is a packaging bug.
//!
//! ## Why a typed manifest matters
//!
//! - `sdk_version` lets the SDK refuse plugins built against an
//!   incompatible major API version (forward-proofing).
//! - `capabilities` is the security surface for the runtime sandbox —
//!   without it we cannot promise Doctrine 4 ("Sandboxed plugins").
//! - `config_schema` is JSON Schema and lets the host validate user
//!   config in `.project/config.kdl` *before* the plugin runs.

use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::Path;
use thiserror::Error;

/// Errors raised by manifest parsing and compatibility checks.
#[derive(Debug, Error)]
pub enum ManifestError {
    #[error("Manifest IO error: {0}")]
    Io(#[from] std::io::Error),

    #[error("Manifest JSON error: {0}")]
    Json(#[from] serde_json::Error),

    #[error("Plugin '{plugin}' targets SDK API {required} which is incompatible with this SDK ({sdk})")]
    IncompatibleSdkVersion {
        plugin: String,
        required: String,
        sdk: String,
    },

    #[error("Plugin '{plugin}' missing capability for '{feature}'. Add it to capabilities in the manifest.")]
    MissingCapability { plugin: String, feature: String },

    #[error("Plugin '{plugin}' declares hook '{hook}' in code but not in manifest.hooks")]
    HookNotDeclared { plugin: String, hook: String },
}

/// Result alias for manifest operations.
pub type ManifestResult<T> = Result<T, ManifestError>;

/// Plugin manifest — the on-disk `.progit-plugin.json`.
///
/// Backwards compatible with v0.1 manifests: missing fields fall back to
/// permissive defaults. New plugins should set `capabilities` explicitly.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginManifest {
    /// Unique plugin id (also the directory name).
    pub name: String,
    /// Semver of the plugin itself.
    pub version: String,
    /// One-line summary, shown in the plugin browser.
    pub description: String,
    /// Author handle or organisation.
    pub author: String,
    /// SPDX identifier (`LSL-1.0`, `LUL-1.0`, `Apache-2.0`, ...).
    pub license: String,
    /// Free-form classification: `notifier` | `integration` | `analytics` | ...
    #[serde(default)]
    pub plugin_type: String,
    /// `lua` or `wasm`.
    #[serde(default = "default_runtime")]
    pub runtime: String,
    /// Required SDK API version (`">=0.2"`, `"^0.2"`, `"0.2"` exact).
    /// Defaults to `">=0.1.0"` for legacy manifests.
    #[serde(default = "default_sdk_constraint")]
    pub sdk_version: String,
    /// Hooks the plugin claims to implement. Cross-checked against the
    /// hooks the Lua / Wasm side actually registers.
    #[serde(default)]
    pub hooks: Vec<String>,
    /// JSON Schema for the plugin's slice of `.project/config.kdl`.
    /// Treated as opaque JSON until a host-side schema validator lands.
    #[serde(default)]
    pub config_schema: Option<serde_json::Value>,
    /// Capability declaration — what this plugin is allowed to do.
    /// Missing = fully permissive (with a deprecation warning).
    #[serde(default)]
    pub capabilities: Option<Capabilities>,
    /// Optional homepage / source URL for the marketplace.
    #[serde(default)]
    pub homepage: Option<String>,
}

fn default_runtime() -> String {
    "lua".to_string()
}
fn default_sdk_constraint() -> String {
    ">=0.1.0".to_string()
}

/// What the plugin is allowed to touch at runtime.
///
/// Defaults are *permissive* on purpose for back-compat. Future major
/// versions will flip this to deny-by-default; the migration path is
/// "add a `capabilities` block to your manifest".
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Capabilities {
    /// May the plugin make outbound HTTP requests via the injected `http` table?
    #[serde(default)]
    pub network: bool,
    /// If set, restricts `http` to these hosts (suffix match — `api.slack.com`
    /// allows `api.slack.com` and `*.api.slack.com`). Empty = any host.
    #[serde(default)]
    pub network_allow: Vec<String>,
    /// May the plugin read/write its private storage via the `storage` table?
    #[serde(default = "default_true")]
    pub storage: bool,
    /// May the plugin read environment variables via `os.getenv`?
    #[serde(default = "default_true")]
    pub env: bool,
    /// May the plugin call the host-provided Sober governance bridge?
    #[serde(default)]
    pub sober: bool,
    /// Maximum heap memory the plugin VM can allocate, in megabytes.
    #[serde(default = "default_memory_mb")]
    pub memory_mb: u32,
    /// Maximum Lua instructions a single hook call may execute.
    #[serde(default = "default_instructions")]
    pub max_instructions: u64,
    /// HTTP request timeout in seconds.
    #[serde(default = "default_http_timeout")]
    pub http_timeout_secs: u64,
    /// May the plugin spawn background jobs via the host-managed async primitive?
    #[serde(default)]
    pub jobs: bool,
    /// May the plugin cancel jobs it previously spawned?
    #[serde(default)]
    pub jobs_cancel: bool,
    /// May the plugin register top-level command aliases?
    #[serde(default)]
    pub commands_alias: bool,
    /// May the plugin subscribe to host events (job progress, file changes)?
    #[serde(default)]
    pub events_subscribe: bool,
    /// May the plugin request a streaming modal surface?
    #[serde(default)]
    pub ui_modal: bool,
    /// May the plugin request auto-activation based on file patterns?
    #[serde(default)]
    pub activation_auto: bool,
}

fn default_true() -> bool {
    true
}
fn default_memory_mb() -> u32 {
    64
}
fn default_instructions() -> u64 {
    10_000_000
}
fn default_http_timeout() -> u64 {
    5
}

impl Capabilities {
    /// Permissive defaults for legacy plugins that ship without a
    /// capability block. Network is *off* by default — plugins must
    /// opt in to the network surface.
    pub fn legacy_default() -> Self {
        Self {
            network: false,
            network_allow: Vec::new(),
            storage: true,
            env: true,
            sober: false,
            memory_mb: default_memory_mb(),
            max_instructions: default_instructions(),
            http_timeout_secs: default_http_timeout(),
            jobs: false,
            jobs_cancel: false,
            commands_alias: false,
            events_subscribe: false,
            ui_modal: false,
            activation_auto: false,
        }
    }

    /// Is `host` allowed by the `network_allow` list?
    /// An empty list means *any* host. Suffix match: `api.slack.com`
    /// in the list permits `api.slack.com` and `hooks.api.slack.com`.
    pub fn host_allowed(&self, host: &str) -> bool {
        if !self.network {
            return false;
        }
        if self.network_allow.is_empty() {
            return true;
        }
        let h = host.to_lowercase();
        self.network_allow
            .iter()
            .any(|allowed| h == allowed.to_lowercase() || h.ends_with(&format!(".{}", allowed.to_lowercase())))
    }
}

impl PluginManifest {
    /// Load and parse a manifest from disk.
    pub fn load(path: &Path) -> ManifestResult<Self> {
        let content = std::fs::read_to_string(path)?;
        Self::from_str(&content)
    }

    /// Parse a manifest from a JSON string.
    pub fn from_str(json: &str) -> ManifestResult<Self> {
        let m: PluginManifest = serde_json::from_str(json)?;
        Ok(m)
    }

    /// Effective capabilities — declared block, or the legacy permissive
    /// default. Use this everywhere the runtime asks "may I?".
    pub fn effective_capabilities(&self) -> Capabilities {
        self.capabilities.clone().unwrap_or_else(Capabilities::legacy_default)
    }

    /// Was the capability block omitted? (Used to emit a one-shot
    /// deprecation warning on plugin load.)
    pub fn capabilities_implicit(&self) -> bool {
        self.capabilities.is_none()
    }

    /// Reject the plugin if its `sdk_version` constraint is incompatible
    /// with the running SDK's API version.
    pub fn check_sdk_compat(&self, sdk_api_version: &str) -> ManifestResult<()> {
        if SdkVersion::parse(sdk_api_version).matches(&self.sdk_version) {
            Ok(())
        } else {
            Err(ManifestError::IncompatibleSdkVersion {
                plugin: self.name.clone(),
                required: self.sdk_version.clone(),
                sdk: sdk_api_version.to_string(),
            })
        }
    }

    /// Confirm every hook implemented by the plugin's runtime side is also
    /// declared in the manifest. Caller passes the hooks observed from
    /// the Lua/Wasm side.
    pub fn check_hooks(&self, observed: &[&str]) -> ManifestResult<()> {
        let declared: HashSet<&str> = self.hooks.iter().map(|s| s.as_str()).collect();
        for h in observed {
            if !declared.contains(*h) {
                return Err(ManifestError::HookNotDeclared {
                    plugin: self.name.clone(),
                    hook: (*h).to_string(),
                });
            }
        }
        Ok(())
    }
}

/// Lightweight `major.minor` SDK version handle.
///
/// We avoid pulling in the full `semver` crate (≈170 KB) for what amounts
/// to a major-equal / minor-at-least check.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SdkVersion {
    pub major: u32,
    pub minor: u32,
}

impl SdkVersion {
    /// Parse `"0.2"` or `"0.2.0"` style strings. Trailing patch is ignored.
    pub fn parse(s: &str) -> Self {
        let mut parts = s.trim().split('.');
        let major = parts.next().and_then(|n| n.parse().ok()).unwrap_or(0);
        let minor = parts.next().and_then(|n| n.parse().ok()).unwrap_or(0);
        Self { major, minor }
    }

    /// Does this SDK version satisfy a constraint like `">=0.2"`,
    /// `"^0.2"`, or `"0.2"` exact?
    ///
    /// Rules:
    /// - `^X.Y` and bare `X.Y`: same major, minor ≥ Y.
    /// - `>=X.Y`: major ≥ X (when X > 0) or (major == X and minor ≥ Y) when X == 0.
    /// - Anything else: try a parse and require equality on `major`.
    pub fn matches(&self, constraint: &str) -> bool {
        let c = constraint.trim();
        if let Some(rest) = c.strip_prefix(">=") {
            let want = Self::parse(rest);
            return self.major > want.major
                || (self.major == want.major && self.minor >= want.minor);
        }
        if let Some(rest) = c.strip_prefix('^') {
            let want = Self::parse(rest);
            return self.major == want.major && self.minor >= want.minor;
        }
        let want = Self::parse(c);
        self.major == want.major && self.minor >= want.minor
    }
}

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

    #[test]
    fn parses_minimal_v01_manifest() {
        let json = r#"{
            "name": "demo",
            "version": "1.0.0",
            "description": "x",
            "author": "y",
            "license": "Apache-2.0"
        }"#;
        let m = PluginManifest::from_str(json).unwrap();
        assert_eq!(m.name, "demo");
        assert_eq!(m.runtime, "lua");
        assert_eq!(m.sdk_version, ">=0.1.0");
        assert!(m.capabilities_implicit());
    }

    #[test]
    fn sdk_version_constraint_matrix() {
        let v02 = SdkVersion::parse("0.2");
        assert!(v02.matches(">=0.1.0"));
        assert!(v02.matches(">=0.2"));
        assert!(v02.matches("^0.2"));
        assert!(v02.matches("0.2"));
        assert!(!v02.matches(">=0.3"));
        assert!(!v02.matches("^0.3"));
        // Major mismatch is always rejected with caret/bare.
        assert!(!v02.matches("^1.0"));
    }

    #[test]
    fn host_allowlist_suffix_match() {
        let mut caps = Capabilities::legacy_default();
        caps.network = true;
        caps.network_allow = vec!["api.slack.com".into(), "hooks.example.org".into()];
        assert!(caps.host_allowed("api.slack.com"));
        assert!(caps.host_allowed("v2.api.slack.com"));
        assert!(!caps.host_allowed("evil.com"));
        assert!(!caps.host_allowed("slack.com")); // not a suffix match
    }

    #[test]
    fn legacy_default_denies_network() {
        let caps = Capabilities::legacy_default();
        assert!(!caps.host_allowed("api.slack.com"));
        assert!(!caps.sober);
    }

    #[test]
    fn rejects_major_mismatch() {
        let json = r#"{
            "name": "future",
            "version": "1.0.0",
            "description": "x",
            "author": "y",
            "license": "MIT",
            "sdk_version": "^1.0"
        }"#;
        let m = PluginManifest::from_str(json).unwrap();
        assert!(m.check_sdk_compat("0.2").is_err());
    }
}