crtx-mcp 0.1.2

MCP stdio JSON-RPC 2.0 server for Cortex — tool dispatch, ToolHandler trait, gate wiring (ADR 0045).
Documentation
//! `cortex_session_commit` MCP tool handler.
//!
//! Schema (ADR 0045 §4, ADR 0047 §3):
//! ```text
//! cortex_session_commit(confirmation_token: string)
//!   → { committed: int, receipt_id: string }
//! ```
//!
//! This tool promotes all `pending_mcp_commit` memories to `active` after
//! verifying that the caller supplied the operator-provided confirmation token
//! (ADR 0047 §3). The token is generated at server startup, printed to stderr
//! only, and never appears in any JSON-RPC response — ensuring that the
//! commit is always driven by an explicit operator action.
//!
//! # Auto-commit mode (`CORTEX_MCP_AUTO_COMMIT=1`)
//!
//! When `cortex serve` is started with `CORTEX_MCP_AUTO_COMMIT=1` in the
//! environment, `CortexSessionCommitTool` is constructed with
//! `auto_commit: true`. In that mode the token check is bypassed entirely
//! and any value (including empty string) is accepted as `confirmation_token`.
//! This is an explicit operator override of the ADR 0047 §3 safety guarantee
//! and MUST only be used in operator-controlled CI contexts.
//!
//! The bypass is logged at `WARN` level with the stable invariant
//! [`SESSION_COMMIT_AUTO_COMMIT_INVARIANT`] so operators can grep for it.
//!
//! # Token comparison
//!
//! Token comparison uses `tokens_equal`, a constant-time fold-XOR over all
//! bytes (ADR 0047 §3). The function always iterates the full length so it
//! does not short-circuit on length mismatch in a way that leaks timing
//! information. This path is skipped when `auto_commit` is `true`.
//!
//! # MemoryRepo::commit_pending_mcp
//!
//! This tool calls `MemoryRepo::commit_pending_mcp(now)` — a method being
//! added to `cortex-store/src/repo/memories.rs` by Lane 1B. The signature:
//! ```text
//! impl MemoryRepo<'_> {
//!     pub fn commit_pending_mcp(&self, now: DateTime<Utc>) -> StoreResult<usize>
//! }
//! ```
//! The method bulk-promotes all rows with `status = 'pending_mcp_commit'`
//! to `status = 'active'` and returns the count of updated rows.

/// Stable invariant token emitted when auto-commit mode bypasses the
/// ADR 0047 §3 confirmation-token check.
///
/// Operators and monitoring pipelines can grep for this string to detect
/// that a `cortex serve` process was started with `CORTEX_MCP_AUTO_COMMIT=1`.
pub const SESSION_COMMIT_AUTO_COMMIT_INVARIANT: &str = "session.commit.auto_commit_mode";

use std::sync::{Arc, Mutex, RwLock};

use chrono::Utc;
use cortex_store::{repo::MemoryRepo, Pool};
use tracing::warn;
use ulid::Ulid;

use crate::tool_handler::{GateId, ToolError, ToolHandler};

/// `cortex_session_commit` tool handler.
///
/// Verifies the operator-supplied confirmation token and then bulk-promotes
/// all `pending_mcp_commit` memories to `active` (ADR 0047 §2 Path A).
///
/// `rusqlite::Connection` is `Send` but not `Sync`; the `Mutex` provides the
/// `Sync` bound required by `ToolHandler` while keeping the connection
/// single-threaded at the call site (the stdio loop is single-threaded).
#[derive(Debug)]
pub struct CortexSessionCommitTool {
    /// SQLite connection guarded by a mutex so the struct satisfies `Sync`.
    pub pool: Arc<Mutex<Pool>>,
    /// The confirmation token generated at server startup (ADR 0047 §3).
    ///
    /// Held in an `Arc<RwLock<Option<String>>>` so `serve.rs` can write the
    /// token once at startup and this tool can read it on each call.
    /// `None` while the server is initialising; always `Some` before the
    /// stdio loop starts.
    pub session_token: Arc<RwLock<Option<String>>>,
    /// When `true`, the ADR 0047 §3 token check is bypassed.
    ///
    /// Set by `serve.rs` when `CORTEX_MCP_AUTO_COMMIT=1` is present in the
    /// environment. This is an explicit operator override of the safety
    /// guarantee; it MUST only be used in operator-controlled CI contexts.
    pub auto_commit: bool,
}

impl CortexSessionCommitTool {
    /// Construct with an explicit session token store.
    ///
    /// Pass `auto_commit: true` only when `CORTEX_MCP_AUTO_COMMIT=1` was set
    /// in the environment at server startup (ADR 0047 operator override).
    #[must_use]
    pub fn new(
        pool: Arc<Mutex<Pool>>,
        session_token: Arc<RwLock<Option<String>>>,
        auto_commit: bool,
    ) -> Self {
        Self {
            pool,
            session_token,
            auto_commit,
        }
    }
}

impl ToolHandler for CortexSessionCommitTool {
    fn name(&self) -> &'static str {
        "cortex_session_commit"
    }

    fn gate_set(&self) -> &'static [GateId] {
        &[GateId::CommitWrite]
    }

    fn call(&self, params: serde_json::Value) -> Result<serde_json::Value, ToolError> {
        // ── 1. Extract confirmation_token ─────────────────────────────────
        let confirmation_token = params
            .get("confirmation_token")
            .and_then(|v| v.as_str())
            .ok_or_else(|| {
                ToolError::InvalidParams(
                    "required parameter `confirmation_token` is missing or not a string".into(),
                )
            })?
            .to_owned();

        // ── 2. Verify the token (or bypass when auto_commit is active) ────
        if self.auto_commit {
            // Token check is bypassed. Emit a WARN so the bypass is always
            // visible in structured logs and greppable by monitoring pipelines.
            warn!(
                invariant = SESSION_COMMIT_AUTO_COMMIT_INVARIANT,
                "cortex_session_commit: auto-commit mode — token check bypassed \
                 (CORTEX_MCP_AUTO_COMMIT=1) [{}]",
                SESSION_COMMIT_AUTO_COMMIT_INVARIANT,
            );
        } else {
            // Normal path: constant-time comparison (ADR 0047 §3).
            if confirmation_token.is_empty() {
                return Err(ToolError::InvalidParams(
                    "confirmation_token must not be empty".into(),
                ));
            }

            // `None` while the server is initialising; always `Some` before the
            // stdio loop starts — a `None` token is a programming error in serve.rs
            // and fails closed.
            let stored_token = self
                .session_token
                .read()
                .map_err(|_| ToolError::Internal("session token lock poisoned".into()))?
                .clone();

            match stored_token {
                None => {
                    // Server token not yet initialised. This indicates a
                    // programming error in serve.rs; fail closed.
                    warn!("cortex_session_commit: session token not initialised — rejecting");
                    return Err(ToolError::Internal(
                        "server session token not initialised".into(),
                    ));
                }
                Some(ref server_token) => {
                    if !tokens_equal(&confirmation_token, server_token) {
                        return Err(ToolError::PolicyRejected(
                            "invalid confirmation token".into(),
                        ));
                    }
                }
            }
        }

        // ── 3. Bulk-promote pending_mcp_commit rows to active ─────────────
        //
        // Lane 1B adds `MemoryRepo::commit_pending_mcp` to
        // `cortex-store/src/repo/memories.rs`. Until that lands the call
        // site compiles against the declared signature stub.
        let now = Utc::now();
        let receipt_id = Ulid::new().to_string();
        let pool_guard = self
            .pool
            .lock()
            .map_err(|_| ToolError::Internal("pool lock poisoned".into()))?;
        let repo = MemoryRepo::new(&pool_guard);
        let committed = repo
            .commit_pending_mcp(&receipt_id, now)
            .map_err(|err| ToolError::Internal(err.to_string()))?;

        // ── 4. Return the ADR 0045 §4 / ADR 0047 schema ──────────────────
        Ok(serde_json::json!({
            "committed":  committed,
            "receipt_id": receipt_id,
        }))
    }
}

/// Constant-time byte comparison to prevent timing oracle attacks on the
/// confirmation token (ADR 0047 §3).
///
/// Constant-time byte-equality check for confirmation tokens (ADR 0047 §3).
///
/// Uses a length-independent fold so that neither a length mismatch nor
/// a byte-value mismatch short-circuits the loop. This prevents timing
/// oracles from leaking the length or a prefix of the server token.
///
/// Implementation: XOR every byte pair over the union of both lengths,
/// using 0 as the out-of-range byte for the shorter string. Accumulate
/// all differing bits into a single `u8`; result is equal iff the
/// accumulator is zero AND the lengths are equal (length check is also
/// folded in as a bit, not a branch).
fn tokens_equal(a: &str, b: &str) -> bool {
    let a = a.as_bytes();
    let b = b.as_bytes();
    let max_len = a.len().max(b.len());
    // Fold XOR of all byte pairs; out-of-range bytes are 0 vs the real byte.
    let diff = (0..max_len).fold(0u8, |acc, i| {
        let x = if i < a.len() { a[i] } else { 0 };
        let y = if i < b.len() { b[i] } else { 0 };
        acc | (x ^ y)
    });
    // Length difference itself is a distinguishing bit — fold it in.
    let len_diff = (a.len() ^ b.len()) as u8; // non-zero when lengths differ
    (diff | len_diff) == 0
}

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

    fn make_tool(token: Option<&str>) -> CortexSessionCommitTool {
        make_tool_with_auto_commit(token, false)
    }

    fn make_tool_with_auto_commit(
        token: Option<&str>,
        auto_commit: bool,
    ) -> CortexSessionCommitTool {
        let pool = Arc::new(Mutex::new(
            cortex_store::Pool::open_in_memory().expect("in-memory sqlite"),
        ));
        let session_token = Arc::new(RwLock::new(token.map(str::to_owned)));
        CortexSessionCommitTool::new(pool, session_token, auto_commit)
    }

    /// Like `make_tool_with_auto_commit` but runs migrations so that the
    /// `memories` table exists and calls that reach the DB succeed.
    fn make_tool_migrated(token: Option<&str>, auto_commit: bool) -> CortexSessionCommitTool {
        let raw = cortex_store::Pool::open_in_memory().expect("in-memory sqlite");
        cortex_store::migrate::apply_pending(&raw).expect("migrations must apply");
        let pool = Arc::new(Mutex::new(raw));
        let session_token = Arc::new(RwLock::new(token.map(str::to_owned)));
        CortexSessionCommitTool::new(pool, session_token, auto_commit)
    }

    /// Missing `confirmation_token` must be rejected.
    #[test]
    fn missing_confirmation_token_returns_invalid_params() {
        let tool = make_tool(Some("correct-token"));
        let err = tool
            .call(serde_json::json!({}))
            .expect_err("must reject missing token");
        assert!(
            matches!(err, ToolError::InvalidParams(_)),
            "expected InvalidParams, got: {err:?}"
        );
    }

    /// Empty `confirmation_token` must be rejected.
    #[test]
    fn empty_confirmation_token_returns_invalid_params() {
        let tool = make_tool(Some("correct-token"));
        let err = tool
            .call(serde_json::json!({ "confirmation_token": "" }))
            .expect_err("must reject empty token");
        assert!(
            matches!(err, ToolError::InvalidParams(_)),
            "expected InvalidParams, got: {err:?}"
        );
    }

    /// Wrong token must return PolicyRejected with the ADR 0047 §3 message.
    #[test]
    fn wrong_token_returns_policy_rejected() {
        let tool = make_tool(Some("correct-token"));
        let err = tool
            .call(serde_json::json!({ "confirmation_token": "wrong-token" }))
            .expect_err("must reject wrong token");
        assert!(
            matches!(err, ToolError::PolicyRejected(_)),
            "expected PolicyRejected, got: {err:?}"
        );
        let msg = err.to_string();
        assert!(
            msg.contains("invalid confirmation token"),
            "error must cite ADR 0047 §3 message: {msg}"
        );
    }

    /// Uninitialised server token must fail with Internal, not PolicyRejected.
    #[test]
    fn uninitialised_server_token_returns_internal() {
        let tool = make_tool(None);
        let err = tool
            .call(serde_json::json!({ "confirmation_token": "anything" }))
            .expect_err("must fail when server token is uninitialised");
        assert!(
            matches!(err, ToolError::Internal(_)),
            "expected Internal, got: {err:?}"
        );
    }

    /// gate_set must declare CommitWrite.
    #[test]
    fn gate_set_declares_commit_write() {
        let tool = make_tool(Some("tok"));
        assert!(
            tool.gate_set().contains(&GateId::CommitWrite),
            "gate_set must include CommitWrite"
        );
    }

    /// Tool name matches the ADR 0045 §4 schema contract.
    #[test]
    fn tool_name_matches_schema_contract() {
        let tool = make_tool(Some("tok"));
        assert_eq!(tool.name(), "cortex_session_commit");
    }

    // ── Auto-commit mode tests ────────────────────────────────────────────

    /// In auto-commit mode any non-empty token is accepted without server-token
    /// comparison.
    #[test]
    fn auto_commit_accepts_arbitrary_token() {
        // Use a migrated pool so the DB call succeeds (no memories → 0 rows committed).
        let tool = make_tool_migrated(Some("server-tok"), true);
        // A token that does NOT match the server token must still succeed.
        let result = tool.call(serde_json::json!({ "confirmation_token": "totally-wrong" }));
        assert!(
            result.is_ok(),
            "auto_commit must accept any non-empty token: {result:?}"
        );
        let val = result.unwrap();
        assert_eq!(val["committed"], 0, "no pending rows → committed must be 0");
    }

    /// In auto-commit mode an empty `confirmation_token` is also accepted
    /// (the empty-check is part of the non-auto-commit path only).
    #[test]
    fn auto_commit_accepts_empty_token() {
        let tool = make_tool_migrated(Some("server-tok"), true);
        let result = tool.call(serde_json::json!({ "confirmation_token": "" }));
        assert!(
            result.is_ok(),
            "auto_commit must accept empty token: {result:?}"
        );
    }

    /// In auto-commit mode a missing `confirmation_token` parameter is still
    /// an InvalidParams error — the parameter must be present in the schema.
    #[test]
    fn auto_commit_rejects_missing_token_param() {
        let tool = make_tool_with_auto_commit(Some("server-tok"), true);
        let err = tool
            .call(serde_json::json!({}))
            .expect_err("missing param must still be rejected in auto_commit mode");
        assert!(
            matches!(err, ToolError::InvalidParams(_)),
            "expected InvalidParams, got: {err:?}"
        );
    }

    /// The auto-commit invariant constant has the expected value so monitoring
    /// pipelines can grep for it.
    #[test]
    fn auto_commit_invariant_constant_value() {
        assert_eq!(
            SESSION_COMMIT_AUTO_COMMIT_INVARIANT,
            "session.commit.auto_commit_mode"
        );
    }
}