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}