Skip to main content

cortex_mcp/tools/
audit_verify.rs

1//! `cortex_audit_verify` MCP tool handler.
2//!
3//! Read-only hash-chain verification of the JSONL event log. Mirrors the
4//! verify pass used by `cortex audit verify`
5//! (`crates/cortex-cli/src/cmd/audit.rs` `run_verify_inner` fn) via
6//! [`cortex_ledger::verify_chain`].
7//!
8//! Returns `{ ok, chain_length, issues }` where `issues` is a list of
9//! human-readable failure strings — one per [`cortex_ledger::audit::RowFailure`]
10//! that the chain walk collected.
11//!
12//! Gate: [`GateId::HealthRead`].
13//! Tier: supervised — logs at every entry.
14
15use std::path::PathBuf;
16use std::sync::{Arc, Mutex};
17
18use cortex_ledger::verify_chain;
19use cortex_store::Pool;
20use serde_json::{json, Value};
21
22use crate::{GateId, ToolError, ToolHandler};
23
24/// MCP tool: `cortex_audit_verify`.
25///
26/// Schema:
27/// ```text
28/// cortex_audit_verify() -> {
29///   ok:           bool,
30///   chain_length: int,
31///   issues:       [string],
32/// }
33/// ```
34///
35/// Calls [`cortex_ledger::verify_chain`] over the configured JSONL event-log
36/// path. `ok` is `true` and `issues` is empty when the chain verified clean.
37/// Any per-row failures are collected into `issues` as human-readable strings.
38///
39/// The `pool` is held to satisfy the [`ToolHandler`] contract for tools that
40/// carry an open store connection; it is not read during the verify pass
41/// (the JSONL file is the authoritative audit surface).
42pub struct CortexAuditVerifyTool {
43    /// Shared store connection (carried for contract symmetry; not used
44    /// during the hash-chain walk).
45    #[allow(dead_code)]
46    pool: Arc<Mutex<Pool>>,
47    /// Path to the JSONL event-log file to verify.
48    event_log: PathBuf,
49}
50
51impl std::fmt::Debug for CortexAuditVerifyTool {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        f.debug_struct("CortexAuditVerifyTool")
54            .field("event_log", &self.event_log)
55            .finish_non_exhaustive()
56    }
57}
58
59impl CortexAuditVerifyTool {
60    /// Construct the tool over a shared store connection and the JSONL event-log path.
61    #[must_use]
62    pub fn new(pool: Arc<Mutex<Pool>>, event_log: PathBuf) -> Self {
63        Self { pool, event_log }
64    }
65}
66
67impl ToolHandler for CortexAuditVerifyTool {
68    fn name(&self) -> &'static str {
69        "cortex_audit_verify"
70    }
71
72    fn gate_set(&self) -> &'static [GateId] {
73        &[GateId::HealthRead]
74    }
75
76    fn call(&self, _params: Value) -> Result<Value, ToolError> {
77        tracing::info!("cortex_audit_verify called via MCP");
78
79        match verify_chain(&self.event_log) {
80            Ok(report) => {
81                let issues: Vec<String> = report
82                    .failures
83                    .iter()
84                    .map(|f| {
85                        let event_part = f
86                            .event_id
87                            .as_ref()
88                            .map_or_else(|| String::from("(no event id)"), ToString::to_string);
89                        format!("line {}: event {} — {:?}", f.line, event_part, f.reason)
90                    })
91                    .collect();
92
93                Ok(json!({
94                    "ok": report.ok(),
95                    "chain_length": report.rows_scanned,
96                    "issues": issues,
97                }))
98            }
99            Err(err) => {
100                // I/O or structural JSONL corruption — not a per-row chain
101                // failure. Surface as a single-element issues list so the
102                // caller can distinguish "file unreadable" from "chain broken".
103                Ok(json!({
104                    "ok": false,
105                    "chain_length": 0,
106                    "issues": [format!("chain verification failed: {err}")],
107                }))
108            }
109        }
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use std::sync::{Arc, Mutex};
117
118    fn make_tool(event_log: PathBuf) -> CortexAuditVerifyTool {
119        // Pool = rusqlite::Connection; open_in_memory() is available on the type alias.
120        let pool = cortex_store::Pool::open_in_memory().expect("in-memory sqlite");
121        CortexAuditVerifyTool::new(Arc::new(Mutex::new(pool)), event_log)
122    }
123
124    #[test]
125    fn name_and_gate() {
126        let tool = make_tool(PathBuf::from("/nonexistent/event.jsonl"));
127        assert_eq!(tool.name(), "cortex_audit_verify");
128        assert!(!tool.gate_set().is_empty());
129        assert_eq!(tool.gate_set(), &[GateId::HealthRead]);
130    }
131
132    #[test]
133    fn missing_event_log_returns_ok_false() {
134        let tool = make_tool(PathBuf::from("/nonexistent/event.jsonl"));
135        let result = tool.call(serde_json::Value::Null).unwrap();
136        assert_eq!(result["ok"], false);
137        assert_eq!(result["chain_length"], 0);
138        let issues = result["issues"].as_array().unwrap();
139        assert!(
140            !issues.is_empty(),
141            "missing file should produce at least one issue"
142        );
143    }
144}