mnemo-mcp-server 0.5.25

Mnemo MCP memory server — runnable binary; install as `cargo install mnemo-mcp-server`, run as `mnemo`.
//! TOML manifest the operator hands `mnemo-mcp-server` via
//! `--manifest <path>`. Designed so an attacker who can spawn the
//! binary cannot pass arbitrary capabilities via env vars or
//! command-line flags — every privileged knob lives in this file.

use std::collections::BTreeSet;
use std::path::{Path, PathBuf};

use mnemo_mcp::role_filter::RoleFilterConfig;
use serde::Deserialize;
use thiserror::Error;

#[derive(Debug, Clone, Deserialize)]
pub struct Manifest {
    pub keystore_path: PathBuf,
    pub audit_log_path: PathBuf,
    #[serde(default = "default_allowed_tools")]
    pub allowed_tools: BTreeSet<String>,
    #[serde(default)]
    pub allowed_agents: BTreeSet<String>,
    #[serde(default = "default_allowed_parents")]
    pub allowed_parents: BTreeSet<String>,
    /// v0.4.0 (P0-1) — optional path to the operator-pinned MCP
    /// tool-catalog file. When set, `mnemo mcp-server` runs the
    /// attestor against the advertised catalog before exposing it
    /// over stdio. Defends against arXiv 2604.20994 function-hijacking
    /// via tool-list poisoning.
    #[serde(default)]
    pub tool_catalog_pin_path: Option<PathBuf>,
    /// v0.4.0 (P0-1) — when true, a `Drift { added: [], mutated: [],
    /// removed: [..] }` verdict allows startup with an audit warning
    /// instead of refusing. Operators set this during a graceful
    /// downgrade where some tools have been intentionally removed.
    #[serde(default)]
    pub allow_removed_drift: bool,
    /// v0.4.2 (A1) — optional `[role_filter]` block aligned with the
    /// MCP authorization spec (2025-11-25, role-based annotations,
    /// <https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization>).
    /// Omitting it preserves pre-v0.4.2 behaviour byte-for-byte
    /// (every advertised tool is reachable, no audit events emitted).
    #[serde(default)]
    pub role_filter: Option<RoleFilterConfig>,
}

fn default_allowed_tools() -> BTreeSet<String> {
    ["mnemo.recall", "mnemo.verify"]
        .into_iter()
        .map(String::from)
        .collect()
}

fn default_allowed_parents() -> BTreeSet<String> {
    ["claude", "claude-code", "systemd", "supervisord"]
        .into_iter()
        .map(String::from)
        .collect()
}

#[derive(Debug, Error)]
pub enum ManifestError {
    #[error("manifest file not found at {path}")]
    NotFound { path: PathBuf },
    #[error("manifest IO error: {0}")]
    Io(#[from] std::io::Error),
    #[error("manifest TOML parse error: {0}")]
    Parse(#[from] toml::de::Error),
    #[error("manifest field {field} is invalid: {reason}")]
    Invalid { field: &'static str, reason: String },
}

impl Manifest {
    pub fn load(path: &Path) -> Result<Self, ManifestError> {
        if !path.exists() {
            return Err(ManifestError::NotFound { path: path.into() });
        }
        let text = std::fs::read_to_string(path)?;
        let m: Manifest = toml::from_str(&text)?;
        m.validate()?;
        Ok(m)
    }

    fn validate(&self) -> Result<(), ManifestError> {
        for t in &self.allowed_tools {
            if !KNOWN_TOOLS.contains(&t.as_str()) {
                return Err(ManifestError::Invalid {
                    field: "allowed_tools",
                    reason: format!("unknown tool {:?}; valid: {:?}", t, KNOWN_TOOLS),
                });
            }
        }
        Ok(())
    }
}

pub const KNOWN_TOOLS: &[&str] = &[
    "mnemo.remember",
    "mnemo.recall",
    "mnemo.reflect",
    "mnemo.forget_subject",
    "mnemo.export_audit_log",
    "mnemo.verify",
];

/// On-disk keystore the manifest points at via `keystore_path`. The
/// HMAC key is hex-encoded so operators can `chmod 0400` a single
/// readable file and rotate by writing a new `key_id` next to a new
/// `key_hex`. Used by the mcp-server hardened mode to attach a
/// `ProvenanceSigner` to the engine without ever passing key material
/// through env vars or argv.
#[derive(Debug, Deserialize)]
pub struct Keystore {
    pub key_id: String,
    pub key_hex: String,
}

impl Keystore {
    pub fn load(path: &Path) -> Result<Self, ManifestError> {
        if !path.exists() {
            return Err(ManifestError::NotFound { path: path.into() });
        }
        let text = std::fs::read_to_string(path)?;
        let k: Keystore = toml::from_str(&text)?;
        if k.key_id.is_empty() {
            return Err(ManifestError::Invalid {
                field: "key_id",
                reason: "must not be empty".into(),
            });
        }
        let bytes = hex::decode(&k.key_hex).map_err(|e| ManifestError::Invalid {
            field: "key_hex",
            reason: format!("not valid hex: {e}"),
        })?;
        if bytes.len() < 32 {
            return Err(ManifestError::Invalid {
                field: "key_hex",
                reason: format!("must decode to >= 32 bytes (got {})", bytes.len()),
            });
        }
        Ok(k)
    }

    pub fn key_bytes(&self) -> Result<Vec<u8>, ManifestError> {
        hex::decode(&self.key_hex).map_err(|e| ManifestError::Invalid {
            field: "key_hex",
            reason: format!("not valid hex: {e}"),
        })
    }
}