Skip to main content

cortex_mcp/tools/
session_commit.rs

1//! `cortex_session_commit` MCP tool handler.
2//!
3//! Schema (ADR 0045 §4, ADR 0047 §3):
4//! ```text
5//! cortex_session_commit(confirmation_token: string)
6//!   → { committed: int, receipt_id: string }
7//! ```
8//!
9//! This tool promotes all `pending_mcp_commit` memories to `active` after
10//! verifying that the caller supplied the operator-provided confirmation token
11//! (ADR 0047 §3). The token is generated at server startup, printed to stderr
12//! only, and never appears in any JSON-RPC response — ensuring that the
13//! commit is always driven by an explicit operator action.
14//!
15//! # Auto-commit mode (`CORTEX_MCP_AUTO_COMMIT=1`)
16//!
17//! When `cortex serve` is started with `CORTEX_MCP_AUTO_COMMIT=1` in the
18//! environment, `CortexSessionCommitTool` is constructed with
19//! `auto_commit: true`. In that mode the token check is bypassed entirely
20//! and any value (including empty string) is accepted as `confirmation_token`.
21//! This is an explicit operator override of the ADR 0047 §3 safety guarantee
22//! and MUST only be used in operator-controlled CI contexts.
23//!
24//! The bypass is logged at `WARN` level with the stable invariant
25//! [`SESSION_COMMIT_AUTO_COMMIT_INVARIANT`] so operators can grep for it.
26//!
27//! # Token comparison
28//!
29//! Token comparison uses `tokens_equal`, a constant-time fold-XOR over all
30//! bytes (ADR 0047 §3). The function always iterates the full length so it
31//! does not short-circuit on length mismatch in a way that leaks timing
32//! information. This path is skipped when `auto_commit` is `true`.
33//!
34//! # MemoryRepo::commit_pending_mcp
35//!
36//! This tool calls `MemoryRepo::commit_pending_mcp(now)` — a method being
37//! added to `cortex-store/src/repo/memories.rs` by Lane 1B. The signature:
38//! ```text
39//! impl MemoryRepo<'_> {
40//!     pub fn commit_pending_mcp(&self, now: DateTime<Utc>) -> StoreResult<usize>
41//! }
42//! ```
43//! The method bulk-promotes all rows with `status = 'pending_mcp_commit'`
44//! to `status = 'active'` and returns the count of updated rows.
45
46/// Stable invariant token emitted when auto-commit mode bypasses the
47/// ADR 0047 §3 confirmation-token check.
48///
49/// Operators and monitoring pipelines can grep for this string to detect
50/// that a `cortex serve` process was started with `CORTEX_MCP_AUTO_COMMIT=1`.
51pub const SESSION_COMMIT_AUTO_COMMIT_INVARIANT: &str = "session.commit.auto_commit_mode";
52
53use std::sync::{Arc, Mutex, RwLock};
54
55use chrono::Utc;
56use cortex_store::{repo::MemoryRepo, Pool};
57use tracing::warn;
58use ulid::Ulid;
59
60use crate::tool_handler::{GateId, ToolError, ToolHandler};
61
62/// `cortex_session_commit` tool handler.
63///
64/// Verifies the operator-supplied confirmation token and then bulk-promotes
65/// all `pending_mcp_commit` memories to `active` (ADR 0047 §2 Path A).
66///
67/// `rusqlite::Connection` is `Send` but not `Sync`; the `Mutex` provides the
68/// `Sync` bound required by `ToolHandler` while keeping the connection
69/// single-threaded at the call site (the stdio loop is single-threaded).
70#[derive(Debug)]
71pub struct CortexSessionCommitTool {
72    /// SQLite connection guarded by a mutex so the struct satisfies `Sync`.
73    pub pool: Arc<Mutex<Pool>>,
74    /// The confirmation token generated at server startup (ADR 0047 §3).
75    ///
76    /// Held in an `Arc<RwLock<Option<String>>>` so `serve.rs` can write the
77    /// token once at startup and this tool can read it on each call.
78    /// `None` while the server is initialising; always `Some` before the
79    /// stdio loop starts.
80    pub session_token: Arc<RwLock<Option<String>>>,
81    /// When `true`, the ADR 0047 §3 token check is bypassed.
82    ///
83    /// Set by `serve.rs` when `CORTEX_MCP_AUTO_COMMIT=1` is present in the
84    /// environment. This is an explicit operator override of the safety
85    /// guarantee; it MUST only be used in operator-controlled CI contexts.
86    pub auto_commit: bool,
87}
88
89impl CortexSessionCommitTool {
90    /// Construct with an explicit session token store.
91    ///
92    /// Pass `auto_commit: true` only when `CORTEX_MCP_AUTO_COMMIT=1` was set
93    /// in the environment at server startup (ADR 0047 operator override).
94    #[must_use]
95    pub fn new(
96        pool: Arc<Mutex<Pool>>,
97        session_token: Arc<RwLock<Option<String>>>,
98        auto_commit: bool,
99    ) -> Self {
100        Self {
101            pool,
102            session_token,
103            auto_commit,
104        }
105    }
106}
107
108impl ToolHandler for CortexSessionCommitTool {
109    fn name(&self) -> &'static str {
110        "cortex_session_commit"
111    }
112
113    fn gate_set(&self) -> &'static [GateId] {
114        &[GateId::CommitWrite]
115    }
116
117    fn call(&self, params: serde_json::Value) -> Result<serde_json::Value, ToolError> {
118        // ── 1. Extract confirmation_token ─────────────────────────────────
119        let confirmation_token = params
120            .get("confirmation_token")
121            .and_then(|v| v.as_str())
122            .ok_or_else(|| {
123                ToolError::InvalidParams(
124                    "required parameter `confirmation_token` is missing or not a string".into(),
125                )
126            })?
127            .to_owned();
128
129        // ── 2. Verify the token (or bypass when auto_commit is active) ────
130        if self.auto_commit {
131            // Token check is bypassed. Emit a WARN so the bypass is always
132            // visible in structured logs and greppable by monitoring pipelines.
133            warn!(
134                invariant = SESSION_COMMIT_AUTO_COMMIT_INVARIANT,
135                "cortex_session_commit: auto-commit mode — token check bypassed \
136                 (CORTEX_MCP_AUTO_COMMIT=1) [{}]",
137                SESSION_COMMIT_AUTO_COMMIT_INVARIANT,
138            );
139        } else {
140            // Normal path: constant-time comparison (ADR 0047 §3).
141            if confirmation_token.is_empty() {
142                return Err(ToolError::InvalidParams(
143                    "confirmation_token must not be empty".into(),
144                ));
145            }
146
147            // `None` while the server is initialising; always `Some` before the
148            // stdio loop starts — a `None` token is a programming error in serve.rs
149            // and fails closed.
150            let stored_token = self
151                .session_token
152                .read()
153                .map_err(|_| ToolError::Internal("session token lock poisoned".into()))?
154                .clone();
155
156            match stored_token {
157                None => {
158                    // Server token not yet initialised. This indicates a
159                    // programming error in serve.rs; fail closed.
160                    warn!("cortex_session_commit: session token not initialised — rejecting");
161                    return Err(ToolError::Internal(
162                        "server session token not initialised".into(),
163                    ));
164                }
165                Some(ref server_token) => {
166                    if !tokens_equal(&confirmation_token, server_token) {
167                        return Err(ToolError::PolicyRejected(
168                            "invalid confirmation token".into(),
169                        ));
170                    }
171                }
172            }
173        }
174
175        // ── 3. Bulk-promote pending_mcp_commit rows to active ─────────────
176        //
177        // Lane 1B adds `MemoryRepo::commit_pending_mcp` to
178        // `cortex-store/src/repo/memories.rs`. Until that lands the call
179        // site compiles against the declared signature stub.
180        let now = Utc::now();
181        let receipt_id = Ulid::new().to_string();
182        let pool_guard = self
183            .pool
184            .lock()
185            .map_err(|_| ToolError::Internal("pool lock poisoned".into()))?;
186        let repo = MemoryRepo::new(&pool_guard);
187        let committed = repo
188            .commit_pending_mcp(&receipt_id, now)
189            .map_err(|err| ToolError::Internal(err.to_string()))?;
190
191        // ── 4. Return the ADR 0045 §4 / ADR 0047 schema ──────────────────
192        Ok(serde_json::json!({
193            "committed":  committed,
194            "receipt_id": receipt_id,
195        }))
196    }
197}
198
199/// Constant-time byte comparison to prevent timing oracle attacks on the
200/// confirmation token (ADR 0047 §3).
201///
202/// Constant-time byte-equality check for confirmation tokens (ADR 0047 §3).
203///
204/// Uses a length-independent fold so that neither a length mismatch nor
205/// a byte-value mismatch short-circuits the loop. This prevents timing
206/// oracles from leaking the length or a prefix of the server token.
207///
208/// Implementation: XOR every byte pair over the union of both lengths,
209/// using 0 as the out-of-range byte for the shorter string. Accumulate
210/// all differing bits into a single `u8`; result is equal iff the
211/// accumulator is zero AND the lengths are equal (length check is also
212/// folded in as a bit, not a branch).
213fn tokens_equal(a: &str, b: &str) -> bool {
214    let a = a.as_bytes();
215    let b = b.as_bytes();
216    let max_len = a.len().max(b.len());
217    // Fold XOR of all byte pairs; out-of-range bytes are 0 vs the real byte.
218    let diff = (0..max_len).fold(0u8, |acc, i| {
219        let x = if i < a.len() { a[i] } else { 0 };
220        let y = if i < b.len() { b[i] } else { 0 };
221        acc | (x ^ y)
222    });
223    // Length difference itself is a distinguishing bit — fold it in.
224    let len_diff = (a.len() ^ b.len()) as u8; // non-zero when lengths differ
225    (diff | len_diff) == 0
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    fn make_tool(token: Option<&str>) -> CortexSessionCommitTool {
233        make_tool_with_auto_commit(token, false)
234    }
235
236    fn make_tool_with_auto_commit(
237        token: Option<&str>,
238        auto_commit: bool,
239    ) -> CortexSessionCommitTool {
240        let pool = Arc::new(Mutex::new(
241            cortex_store::Pool::open_in_memory().expect("in-memory sqlite"),
242        ));
243        let session_token = Arc::new(RwLock::new(token.map(str::to_owned)));
244        CortexSessionCommitTool::new(pool, session_token, auto_commit)
245    }
246
247    /// Like `make_tool_with_auto_commit` but runs migrations so that the
248    /// `memories` table exists and calls that reach the DB succeed.
249    fn make_tool_migrated(token: Option<&str>, auto_commit: bool) -> CortexSessionCommitTool {
250        let raw = cortex_store::Pool::open_in_memory().expect("in-memory sqlite");
251        cortex_store::migrate::apply_pending(&raw).expect("migrations must apply");
252        let pool = Arc::new(Mutex::new(raw));
253        let session_token = Arc::new(RwLock::new(token.map(str::to_owned)));
254        CortexSessionCommitTool::new(pool, session_token, auto_commit)
255    }
256
257    /// Missing `confirmation_token` must be rejected.
258    #[test]
259    fn missing_confirmation_token_returns_invalid_params() {
260        let tool = make_tool(Some("correct-token"));
261        let err = tool
262            .call(serde_json::json!({}))
263            .expect_err("must reject missing token");
264        assert!(
265            matches!(err, ToolError::InvalidParams(_)),
266            "expected InvalidParams, got: {err:?}"
267        );
268    }
269
270    /// Empty `confirmation_token` must be rejected.
271    #[test]
272    fn empty_confirmation_token_returns_invalid_params() {
273        let tool = make_tool(Some("correct-token"));
274        let err = tool
275            .call(serde_json::json!({ "confirmation_token": "" }))
276            .expect_err("must reject empty token");
277        assert!(
278            matches!(err, ToolError::InvalidParams(_)),
279            "expected InvalidParams, got: {err:?}"
280        );
281    }
282
283    /// Wrong token must return PolicyRejected with the ADR 0047 §3 message.
284    #[test]
285    fn wrong_token_returns_policy_rejected() {
286        let tool = make_tool(Some("correct-token"));
287        let err = tool
288            .call(serde_json::json!({ "confirmation_token": "wrong-token" }))
289            .expect_err("must reject wrong token");
290        assert!(
291            matches!(err, ToolError::PolicyRejected(_)),
292            "expected PolicyRejected, got: {err:?}"
293        );
294        let msg = err.to_string();
295        assert!(
296            msg.contains("invalid confirmation token"),
297            "error must cite ADR 0047 §3 message: {msg}"
298        );
299    }
300
301    /// Uninitialised server token must fail with Internal, not PolicyRejected.
302    #[test]
303    fn uninitialised_server_token_returns_internal() {
304        let tool = make_tool(None);
305        let err = tool
306            .call(serde_json::json!({ "confirmation_token": "anything" }))
307            .expect_err("must fail when server token is uninitialised");
308        assert!(
309            matches!(err, ToolError::Internal(_)),
310            "expected Internal, got: {err:?}"
311        );
312    }
313
314    /// gate_set must declare CommitWrite.
315    #[test]
316    fn gate_set_declares_commit_write() {
317        let tool = make_tool(Some("tok"));
318        assert!(
319            tool.gate_set().contains(&GateId::CommitWrite),
320            "gate_set must include CommitWrite"
321        );
322    }
323
324    /// Tool name matches the ADR 0045 §4 schema contract.
325    #[test]
326    fn tool_name_matches_schema_contract() {
327        let tool = make_tool(Some("tok"));
328        assert_eq!(tool.name(), "cortex_session_commit");
329    }
330
331    // ── Auto-commit mode tests ────────────────────────────────────────────
332
333    /// In auto-commit mode any non-empty token is accepted without server-token
334    /// comparison.
335    #[test]
336    fn auto_commit_accepts_arbitrary_token() {
337        // Use a migrated pool so the DB call succeeds (no memories → 0 rows committed).
338        let tool = make_tool_migrated(Some("server-tok"), true);
339        // A token that does NOT match the server token must still succeed.
340        let result = tool.call(serde_json::json!({ "confirmation_token": "totally-wrong" }));
341        assert!(
342            result.is_ok(),
343            "auto_commit must accept any non-empty token: {result:?}"
344        );
345        let val = result.unwrap();
346        assert_eq!(val["committed"], 0, "no pending rows → committed must be 0");
347    }
348
349    /// In auto-commit mode an empty `confirmation_token` is also accepted
350    /// (the empty-check is part of the non-auto-commit path only).
351    #[test]
352    fn auto_commit_accepts_empty_token() {
353        let tool = make_tool_migrated(Some("server-tok"), true);
354        let result = tool.call(serde_json::json!({ "confirmation_token": "" }));
355        assert!(
356            result.is_ok(),
357            "auto_commit must accept empty token: {result:?}"
358        );
359    }
360
361    /// In auto-commit mode a missing `confirmation_token` parameter is still
362    /// an InvalidParams error — the parameter must be present in the schema.
363    #[test]
364    fn auto_commit_rejects_missing_token_param() {
365        let tool = make_tool_with_auto_commit(Some("server-tok"), true);
366        let err = tool
367            .call(serde_json::json!({}))
368            .expect_err("missing param must still be rejected in auto_commit mode");
369        assert!(
370            matches!(err, ToolError::InvalidParams(_)),
371            "expected InvalidParams, got: {err:?}"
372        );
373    }
374
375    /// The auto-commit invariant constant has the expected value so monitoring
376    /// pipelines can grep for it.
377    #[test]
378    fn auto_commit_invariant_constant_value() {
379        assert_eq!(
380            SESSION_COMMIT_AUTO_COMMIT_INVARIANT,
381            "session.commit.auto_commit_mode"
382        );
383    }
384}