Skip to main content

cortex_mcp/tools/
memory_accept.rs

1//! `cortex_memory_accept` MCP tool handler.
2//!
3//! Promotes a candidate memory to active after verifying the operator's
4//! server-side confirmation token (ADR 0047 §3). The old caller-supplied
5//! `confirmed: true` pattern (RT1-F4) is replaced with a `confirmation_token`
6//! parameter that must match the token generated at server startup — the same
7//! mechanism used by `CortexSessionCommitTool`.
8//!
9//! After token validation the handler composes the full ADR 0026 policy
10//! envelope (proof-closure, contradiction, semantic-trust,
11//! operator-temporal-use) and delegates to [`cortex_memory::accept`], exactly
12//! as `cortex memory accept` does in the CLI.
13//!
14//! Gates: [`GateId::CommitWrite`].
15
16use std::sync::{Arc, Mutex, RwLock};
17
18use chrono::Utc;
19use cortex_core::{
20    compose_policy_outcomes, AuditRecordId, MemoryId, PolicyContribution, PolicyOutcome,
21};
22use cortex_memory::accept as memory_accept;
23use cortex_store::{
24    repo::{
25        memories::{
26            accept_open_contradiction_contribution, accept_proof_closure_contribution,
27            ACCEPT_OPERATOR_TEMPORAL_USE_RULE_ID, ACCEPT_SEMANTIC_TRUST_RULE_ID,
28        },
29        ContradictionRepo, MemoryAcceptanceAudit, MemoryRepo,
30    },
31    verify_memory_proof_closure, Pool,
32};
33use serde_json::{json, Value};
34use tracing::warn;
35
36use crate::tool_handler::{GateId, ToolError, ToolHandler};
37
38/// Stable invariant emitted when `memory_accept` bypasses the ADR 0047 §3
39/// token check via auto-commit mode.
40pub const MEMORY_ACCEPT_AUTO_COMMIT_INVARIANT: &str = "memory.accept.auto_commit_mode";
41
42/// The `Warn`-floor invariant for the operator temporal-use contributor when
43/// no operator attestation is bound on this surface (honest no-attestation
44/// floor, not a BreakGlass substitution).
45const ACCEPT_OPERATOR_TEMPORAL_AUTHORITY_WARN_NO_ATTESTATION_INVARIANT: &str =
46    "memory.accept.operator_temporal_authority.warn_no_attestation";
47
48/// MCP tool: `cortex_memory_accept`.
49///
50/// Schema:
51/// ```text
52/// cortex_memory_accept(
53///   memory_id:          string,
54///   confirmation_token: string,
55/// ) ->
56///   { accepted: bool, memory_id: string }
57/// ```
58#[derive(Debug)]
59pub struct CortexMemoryAcceptTool {
60    pool: Arc<Mutex<Pool>>,
61    /// The confirmation token generated at server startup (ADR 0047 §3).
62    ///
63    /// Shared with `CortexSessionCommitTool` via the same
64    /// `Arc<RwLock<Option<String>>>` written once by `serve.rs` before the
65    /// stdio loop starts.
66    pub session_token: Arc<RwLock<Option<String>>>,
67    /// When `true`, the ADR 0047 §3 token check is bypassed.
68    ///
69    /// Set by `serve.rs` when `CORTEX_MCP_AUTO_COMMIT=1` is present in the
70    /// environment. MUST only be used in operator-controlled CI contexts.
71    pub auto_commit: bool,
72}
73
74impl CortexMemoryAcceptTool {
75    /// Construct the tool over a shared, mutex-guarded store connection and
76    /// the server-side confirmation token.
77    #[must_use]
78    pub fn new(
79        pool: Arc<Mutex<Pool>>,
80        session_token: Arc<RwLock<Option<String>>>,
81        auto_commit: bool,
82    ) -> Self {
83        Self {
84            pool,
85            session_token,
86            auto_commit,
87        }
88    }
89}
90
91impl ToolHandler for CortexMemoryAcceptTool {
92    fn name(&self) -> &'static str {
93        "cortex_memory_accept"
94    }
95
96    fn gate_set(&self) -> &'static [GateId] {
97        &[GateId::CommitWrite]
98    }
99
100    fn call(&self, params: Value) -> Result<Value, ToolError> {
101        // ── 1. Extract memory_id ──────────────────────────────────────────
102        let memory_id_str = params["memory_id"]
103            .as_str()
104            .filter(|s| !s.is_empty())
105            .ok_or_else(|| ToolError::InvalidParams("memory_id is required".into()))?;
106
107        // ── 2. Extract confirmation_token ─────────────────────────────────
108        let confirmation_token = params
109            .get("confirmation_token")
110            .and_then(|v| v.as_str())
111            .ok_or_else(|| {
112                ToolError::InvalidParams(
113                    "required parameter `confirmation_token` is missing or not a string".into(),
114                )
115            })?
116            .to_owned();
117
118        // ── 3. Verify the token (or bypass when auto_commit is active) ────
119        if self.auto_commit {
120            warn!(
121                invariant = MEMORY_ACCEPT_AUTO_COMMIT_INVARIANT,
122                "cortex_memory_accept: auto-commit mode — token check bypassed \
123                 (CORTEX_MCP_AUTO_COMMIT=1) [{}]",
124                MEMORY_ACCEPT_AUTO_COMMIT_INVARIANT,
125            );
126        } else {
127            if confirmation_token.is_empty() {
128                return Err(ToolError::InvalidParams(
129                    "confirmation_token must not be empty".into(),
130                ));
131            }
132
133            let stored_token = self
134                .session_token
135                .read()
136                .map_err(|_| ToolError::Internal("session token lock poisoned".into()))?
137                .clone();
138
139            match stored_token {
140                None => {
141                    warn!("cortex_memory_accept: session token not initialised — rejecting");
142                    return Err(ToolError::Internal(
143                        "server session token not initialised".into(),
144                    ));
145                }
146                Some(ref server_token) => {
147                    if !tokens_equal(&confirmation_token, server_token) {
148                        return Err(ToolError::PolicyRejected(
149                            "invalid confirmation token".into(),
150                        ));
151                    }
152                }
153            }
154        }
155
156        // ── 4. Parse and validate the memory ID ──────────────────────────
157        let memory_id: MemoryId = memory_id_str.parse().map_err(|err| {
158            ToolError::InvalidParams(format!("memory_id `{memory_id_str}` is invalid: {err}"))
159        })?;
160
161        tracing::info!("cortex_memory_accept via MCP: memory_id={}", memory_id);
162
163        // ── 5. Compose the ADR 0026 policy envelope ───────────────────────
164        let pool_guard = self
165            .pool
166            .lock()
167            .map_err(|err| ToolError::Internal(format!("pool lock poisoned: {err}")))?;
168
169        let proof_report =
170            verify_memory_proof_closure(&pool_guard, &memory_id).map_err(|err| {
171                ToolError::Internal(format!(
172                    "proof closure preflight failed for {memory_id}: {err}"
173                ))
174            })?;
175        let proof_contribution = accept_proof_closure_contribution(&proof_report);
176
177        let candidate_ref = memory_id.to_string();
178        let contradictions = ContradictionRepo::new(&pool_guard)
179            .list_open()
180            .map_err(|err| {
181                ToolError::Internal(format!(
182                    "contradiction preflight failed for {memory_id}: {err}"
183                ))
184            })?;
185        let open_contradictions = contradictions
186            .iter()
187            .filter(|row| row.left_ref == candidate_ref || row.right_ref == candidate_ref)
188            .count();
189        let contradiction_contribution = accept_open_contradiction_contribution(open_contradictions);
190
191        let semantic_trust_contribution = PolicyContribution::new(
192            ACCEPT_SEMANTIC_TRUST_RULE_ID,
193            PolicyOutcome::Allow,
194            "mcp operator confirmation token validated: \
195             candidate passed lineage validation upstream",
196        )
197        .expect("static semantic trust contribution shape is valid");
198
199        let operator_temporal_use_contribution = PolicyContribution::new(
200            ACCEPT_OPERATOR_TEMPORAL_USE_RULE_ID,
201            PolicyOutcome::Warn,
202            format!(
203                "{ACCEPT_OPERATOR_TEMPORAL_AUTHORITY_WARN_NO_ATTESTATION_INVARIANT}: \
204                 no operator attestation bound on this MCP surface; accepting at the honest floor",
205            ),
206        )
207        .expect("static operator temporal use contribution shape is valid");
208
209        let policy = compose_policy_outcomes(
210            vec![
211                proof_contribution,
212                contradiction_contribution,
213                semantic_trust_contribution,
214                operator_temporal_use_contribution,
215            ],
216            None,
217        );
218
219        // ── 6. Execute the accept via the cortex_memory lifecycle layer ───
220        let repo = MemoryRepo::new(&pool_guard);
221        let audit = MemoryAcceptanceAudit {
222            id: AuditRecordId::new(),
223            actor_json: json!({"kind": "mcp", "tool": "cortex_memory_accept"}),
224            reason: "operator accepted candidate memory via MCP confirmation token".to_string(),
225            source_refs_json: json!([memory_id.to_string()]),
226            created_at: Utc::now(),
227        };
228
229        let accepted_id =
230            memory_accept(&repo, &memory_id, Utc::now(), &audit, &policy, &proof_report)
231                .map_err(|err| ToolError::PolicyRejected(err.to_string()))?;
232
233        Ok(json!({
234            "accepted": true,
235            "memory_id": accepted_id.to_string(),
236        }))
237    }
238}
239
240/// Constant-time byte comparison to prevent timing oracle attacks on the
241/// confirmation token (ADR 0047 §3).
242///
243/// Returns `true` only when both strings are identical. The comparison
244/// always iterates over every byte of the shorter string even when lengths
245/// differ, so it does not short-circuit on length mismatch in a way that
246/// leaks information via timing.
247fn tokens_equal(a: &str, b: &str) -> bool {
248    if a.len() != b.len() {
249        return false;
250    }
251    a.bytes()
252        .zip(b.bytes())
253        .fold(0u8, |acc, (x, y)| acc | (x ^ y))
254        == 0
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    fn make_tool(token: Option<&str>) -> CortexMemoryAcceptTool {
262        make_tool_with_auto_commit(token, false)
263    }
264
265    fn make_tool_with_auto_commit(
266        token: Option<&str>,
267        auto_commit: bool,
268    ) -> CortexMemoryAcceptTool {
269        let pool = Arc::new(Mutex::new(
270            cortex_store::Pool::open_in_memory().expect("in-memory sqlite"),
271        ));
272        let session_token = Arc::new(RwLock::new(token.map(str::to_owned)));
273        CortexMemoryAcceptTool::new(pool, session_token, auto_commit)
274    }
275
276    /// Missing `confirmation_token` must be rejected with InvalidParams.
277    #[test]
278    fn missing_confirmation_token_returns_invalid_params() {
279        let tool = make_tool(Some("correct-token"));
280        let err = tool
281            .call(serde_json::json!({"memory_id": "01JSVFAKEAAAAAAAAAAAAAAAAA"}))
282            .expect_err("must reject missing token");
283        assert!(
284            matches!(err, ToolError::InvalidParams(_)),
285            "expected InvalidParams, got: {err:?}"
286        );
287    }
288
289    /// Empty `confirmation_token` must be rejected with InvalidParams.
290    #[test]
291    fn empty_confirmation_token_returns_invalid_params() {
292        let tool = make_tool(Some("correct-token"));
293        let err = tool
294            .call(serde_json::json!({
295                "memory_id": "01JSVFAKEAAAAAAAAAAAAAAAAA",
296                "confirmation_token": ""
297            }))
298            .expect_err("must reject empty token");
299        assert!(
300            matches!(err, ToolError::InvalidParams(_)),
301            "expected InvalidParams, got: {err:?}"
302        );
303    }
304
305    /// Wrong token must return PolicyRejected.
306    #[test]
307    fn wrong_token_returns_policy_rejected() {
308        let tool = make_tool(Some("correct-token"));
309        let err = tool
310            .call(serde_json::json!({
311                "memory_id": "01JSVFAKEAAAAAAAAAAAAAAAAA",
312                "confirmation_token": "wrong-token"
313            }))
314            .expect_err("must reject wrong token");
315        assert!(
316            matches!(err, ToolError::PolicyRejected(_)),
317            "expected PolicyRejected, got: {err:?}"
318        );
319        let msg = err.to_string();
320        assert!(
321            msg.contains("invalid confirmation token"),
322            "error must cite ADR 0047 §3 message: {msg}"
323        );
324    }
325
326    /// Uninitialised server token must fail with Internal, not PolicyRejected.
327    #[test]
328    fn uninitialised_server_token_returns_internal() {
329        let tool = make_tool(None);
330        let err = tool
331            .call(serde_json::json!({
332                "memory_id": "01JSVFAKEAAAAAAAAAAAAAAAAA",
333                "confirmation_token": "anything"
334            }))
335            .expect_err("must fail when server token is uninitialised");
336        assert!(
337            matches!(err, ToolError::Internal(_)),
338            "expected Internal, got: {err:?}"
339        );
340    }
341
342    /// Missing `memory_id` must be rejected with InvalidParams.
343    #[test]
344    fn missing_memory_id_returns_invalid_params() {
345        let tool = make_tool(Some("tok"));
346        let err = tool
347            .call(serde_json::json!({"confirmation_token": "tok"}))
348            .expect_err("must reject missing memory_id");
349        assert!(
350            matches!(err, ToolError::InvalidParams(_)),
351            "expected InvalidParams, got: {err:?}"
352        );
353    }
354
355    /// gate_set must declare CommitWrite.
356    #[test]
357    fn gate_set_declares_commit_write() {
358        let tool = make_tool(Some("tok"));
359        assert!(
360            tool.gate_set().contains(&GateId::CommitWrite),
361            "gate_set must include CommitWrite"
362        );
363    }
364
365    /// gate_set must NOT declare SessionWrite (that was the old, incorrect wiring).
366    #[test]
367    fn gate_set_does_not_declare_session_write() {
368        let tool = make_tool(Some("tok"));
369        assert!(
370            !tool.gate_set().contains(&GateId::SessionWrite),
371            "gate_set must not include SessionWrite"
372        );
373    }
374
375    /// Tool name matches the MCP schema contract.
376    #[test]
377    fn tool_name_matches_schema_contract() {
378        let tool = make_tool(Some("tok"));
379        assert_eq!(tool.name(), "cortex_memory_accept");
380    }
381
382    /// In auto-commit mode a missing `confirmation_token` is still InvalidParams.
383    #[test]
384    fn auto_commit_rejects_missing_token_param() {
385        let tool = make_tool_with_auto_commit(Some("server-tok"), true);
386        let err = tool
387            .call(serde_json::json!({"memory_id": "01JSVFAKEAAAAAAAAAAAAAAAAA"}))
388            .expect_err("missing param must still be rejected in auto_commit mode");
389        assert!(
390            matches!(err, ToolError::InvalidParams(_)),
391            "expected InvalidParams, got: {err:?}"
392        );
393    }
394
395    /// The auto-commit invariant constant has the expected value.
396    #[test]
397    fn auto_commit_invariant_constant_value() {
398        assert_eq!(
399            MEMORY_ACCEPT_AUTO_COMMIT_INVARIANT,
400            "memory.accept.auto_commit_mode"
401        );
402    }
403
404    /// Regression guard for RT1-F4: a caller-supplied `confirmed: true` must
405    /// NOT bypass the token check.
406    ///
407    /// Passing `confirmed: true` without a valid `confirmation_token` must
408    /// fail with InvalidParams (missing required param), not succeed.
409    #[test]
410    fn confirmed_bool_true_does_not_bypass_token_check() {
411        let tool = make_tool(Some("correct-token"));
412        // Pass the old vulnerable parameter — no `confirmation_token`.
413        let err = tool
414            .call(serde_json::json!({
415                "memory_id": "01JSVFAKEAAAAAAAAAAAAAAAAA",
416                "confirmed": true
417            }))
418            .expect_err("confirmed:true without token must be rejected");
419        assert!(
420            matches!(err, ToolError::InvalidParams(_)),
421            "expected InvalidParams (missing confirmation_token), got: {err:?}"
422        );
423    }
424}