mod parser;
mod search;
mod server;
mod watcher;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use canon_core::proof::{self, FileSnapshot, GitContext, SessionEvent, SessionProof, UserPrompt};
use canon_embed::EmbeddingEngine;
use canon_store::GraphStore;
use canon_core::DeviceIdentity;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use tracing::info;
#[derive(Parser)]
#[command(name = "canon-mcp")]
#[command(about = "Canon Protocol MCP server — cryptographic audit trails for AI")]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
#[arg(long)]
watch: Option<PathBuf>,
#[arg(long, default_value = ".canon")]
data_dir: PathBuf,
}
#[derive(Subcommand)]
enum Commands {
Init,
SessionProof {
#[arg(long, default_value = ".canon")]
data_dir: PathBuf,
},
Verify {
path: PathBuf,
},
}
fn detect_git_context() -> Option<GitContext> {
use std::process::Command;
let commit = Command::new("git")
.args(["rev-parse", "HEAD"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
let branch = Command::new("git")
.args(["branch", "--show-current"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty());
let name = Command::new("git")
.args(["config", "user.name"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
let email = Command::new("git")
.args(["config", "user.email"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
let author = match (name, email) {
(Some(n), Some(e)) => Some(format!("{} <{}>", n, e)),
(Some(n), None) => Some(n),
_ => None,
};
let dirty = Command::new("git")
.args(["status", "--porcelain"])
.output()
.ok()
.map(|o| !o.stdout.is_empty())
.unwrap_or(false);
commit.as_ref()?;
Some(GitContext {
commit,
branch,
author,
dirty,
})
}
fn try_load_git_signing_key() -> Option<[u8; 32]> {
use std::process::Command;
let gpg_format = Command::new("git")
.args(["config", "gpg.format"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())?;
if gpg_format != "ssh" {
return None;
}
let signing_key = Command::new("git")
.args(["config", "user.signingkey"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())?;
let key_path = if signing_key.starts_with('~') {
dirs_or_home().map(|h| h.join(&signing_key[2..]))?
} else {
PathBuf::from(&signing_key)
};
if !key_path.exists() {
return None;
}
let key_data = std::fs::read_to_string(&key_path).ok()?;
parse_openssh_ed25519_seed(&key_data)
}
fn parse_openssh_ed25519_seed(pem: &str) -> Option<[u8; 32]> {
use base64::Engine;
let b64: String = pem
.lines()
.filter(|l| !l.starts_with("-----"))
.collect();
let decoded = base64::engine::general_purpose::STANDARD.decode(&b64).ok()?;
let magic = b"openssh-key-v1\0";
if decoded.len() < magic.len() || &decoded[..magic.len()] != magic {
return None;
}
let mut pos = magic.len();
let read_u32 = |data: &[u8], p: &mut usize| -> Option<u32> {
if *p + 4 > data.len() { return None; }
let val = u32::from_be_bytes(data[*p..*p + 4].try_into().ok()?);
*p += 4;
Some(val)
};
let read_string = |data: &[u8], p: &mut usize| -> Option<Vec<u8>> {
let len = read_u32(data, p)? as usize;
if *p + len > data.len() { return None; }
let val = data[*p..*p + len].to_vec();
*p += len;
Some(val)
};
let cipher = read_string(&decoded, &mut pos)?;
if cipher != b"none" {
return None;
}
let _kdf = read_string(&decoded, &mut pos)?;
let _kdfopts = read_string(&decoded, &mut pos)?;
let nkeys = read_u32(&decoded, &mut pos)?;
if nkeys != 1 { return None; }
let _pubkey = read_string(&decoded, &mut pos)?;
let priv_section = read_string(&decoded, &mut pos)?;
let mut pp = 0;
let check1 = read_u32(&priv_section, &mut pp)?;
let check2 = read_u32(&priv_section, &mut pp)?;
if check1 != check2 { return None; }
let key_type = read_string(&priv_section, &mut pp)?;
if key_type != b"ssh-ed25519" { return None; }
let _pub_data = read_string(&priv_section, &mut pp)?;
let priv_data = read_string(&priv_section, &mut pp)?;
if priv_data.len() != 64 { return None; }
let mut seed = [0u8; 32];
seed.copy_from_slice(&priv_data[..32]);
Some(seed)
}
fn dirs_or_home() -> Option<PathBuf> {
std::env::var("HOME").ok().map(PathBuf::from)
}
fn load_or_create_identity(data_dir: &std::path::Path) -> Result<DeviceIdentity> {
if let Some(seed) = try_load_git_signing_key() {
info!("Using git Ed25519 SSH signing key for device identity");
return Ok(DeviceIdentity::from_seed(seed));
}
let key_path = data_dir.join("cp.key");
if key_path.exists() {
let seed_bytes = std::fs::read(&key_path)?;
if seed_bytes.len() == 32 {
let mut seed = [0u8; 32];
seed.copy_from_slice(&seed_bytes);
return Ok(DeviceIdentity::from_seed(seed));
}
}
let identity = DeviceIdentity::generate();
std::fs::write(&key_path, &identity.export_seed())?;
info!("Generated new device identity at {:?}", key_path);
Ok(identity)
}
fn ensure_gitignore(data_dir: &std::path::Path) -> Result<()> {
let gitignore_path = data_dir.join(".gitignore");
if !gitignore_path.exists() {
std::fs::write(
&gitignore_path,
"graph.db\ncp.key\nhnsw.idx\n*.usearch\nsession.jsonl\n",
)?;
}
Ok(())
}
fn init_project() -> Result<()> {
let canon_mcp_path = std::env::current_exe()
.unwrap_or_else(|_| PathBuf::from("canon-mcp"))
.to_string_lossy()
.to_string();
std::fs::create_dir_all(".canon/hooks")?;
std::fs::create_dir_all(".canon/proofs")?;
std::fs::create_dir_all(".claude")?;
let mcp_json_path = PathBuf::from(".mcp.json");
if mcp_json_path.exists() {
println!(" .mcp.json already exists, skipping");
} else {
let mcp_json = format!(r#"{{
"mcpServers": {{
"canon": {{
"command": "{}",
"args": ["--watch", ".", "--data-dir", ".canon"]
}}
}}
}}
"#, canon_mcp_path);
std::fs::write(&mcp_json_path, mcp_json)?;
println!(" Created .mcp.json");
}
let post_tool = r#"#!/bin/bash
# Canon Protocol — PostToolUse hook
# Logs every tool call with context: what was sent and what came back.
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // "unknown"')
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"')
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
INPUT_PREVIEW=""
OUTPUT_PREVIEW=""
case "$TOOL_NAME" in
Read)
OUTPUT_PREVIEW=$(echo "$INPUT" | jq -r '.tool_response // empty' | head -c 1000)
;;
Write)
INPUT_PREVIEW=$(echo "$INPUT" | jq -r '.tool_input.content // empty' | head -c 1000)
;;
Edit)
OLD=$(echo "$INPUT" | jq -r '.tool_input.old_string // empty' | head -c 500)
NEW=$(echo "$INPUT" | jq -r '.tool_input.new_string // empty' | head -c 500)
INPUT_PREVIEW=$(printf "-%s\n+%s" "$OLD" "$NEW")
;;
Bash)
INPUT_PREVIEW=$(echo "$INPUT" | jq -r '.tool_input.command // empty' | head -c 500)
OUTPUT_PREVIEW=$(echo "$INPUT" | jq -r '.tool_response // empty' | head -c 1000)
;;
Grep|Glob)
INPUT_PREVIEW=$(echo "$INPUT" | jq -r '.tool_input.pattern // empty')
OUTPUT_PREVIEW=$(echo "$INPUT" | jq -r '.tool_response // empty' | head -c 1000)
;;
esac
CANON_DIR=".canon"
if [ ! -d "$CANON_DIR" ]; then
CANON_DIR="$(pwd)/.canon"
fi
jq -cn \
--arg tool_name "$TOOL_NAME" \
--arg file_path "$FILE_PATH" \
--arg session_id "$SESSION_ID" \
--arg timestamp "$TIMESTAMP" \
--arg input_preview "$INPUT_PREVIEW" \
--arg output_preview "$OUTPUT_PREVIEW" \
'{tool_name: $tool_name, tool_input: {file_path: $file_path}, session_id: $session_id, timestamp: $timestamp, input_preview: $input_preview, output_preview: $output_preview}' \
>> "$CANON_DIR/session.jsonl"
"#;
std::fs::write(".canon/hooks/post-tool.sh", post_tool)?;
set_executable(".canon/hooks/post-tool.sh")?;
println!(" Created .canon/hooks/post-tool.sh");
let prompt_hook = r#"#!/bin/bash
# Canon Protocol — UserPromptSubmit hook
# Logs the user's prompt so we know what the AI was asked.
INPUT=$(cat)
PROMPT=$(echo "$INPUT" | jq -r '.prompt // empty')
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"')
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
PROMPT_TRUNC=$(echo "$PROMPT" | head -c 2000)
CANON_DIR=".canon"
if [ ! -d "$CANON_DIR" ]; then
CANON_DIR="$(pwd)/.canon"
fi
jq -cn \
--arg type "user_prompt" \
--arg prompt "$PROMPT_TRUNC" \
--arg session_id "$SESSION_ID" \
--arg timestamp "$TIMESTAMP" \
'{type: $type, prompt: $prompt, session_id: $session_id, timestamp: $timestamp}' \
>> "$CANON_DIR/session.jsonl"
"#;
std::fs::write(".canon/hooks/prompt.sh", prompt_hook)?;
set_executable(".canon/hooks/prompt.sh")?;
println!(" Created .canon/hooks/prompt.sh");
let stop_hook = format!(r#"#!/bin/bash
# Canon Protocol — Stop hook
# When Claude finishes responding, generate a signed session proof.
CANON_MCP="{}"
if [ -s ".canon/session.jsonl" ]; then
"$CANON_MCP" session-proof --data-dir .canon 2>&1
fi
"#, canon_mcp_path);
std::fs::write(".canon/hooks/stop.sh", &stop_hook)?;
set_executable(".canon/hooks/stop.sh")?;
println!(" Created .canon/hooks/stop.sh");
let settings_path = PathBuf::from(".claude/settings.local.json");
if settings_path.exists() {
let existing = std::fs::read_to_string(&settings_path)?;
if existing.contains("PostToolUse") {
println!(" .claude/settings.local.json already has hooks, skipping");
} else {
if let Ok(mut val) = serde_json::from_str::<serde_json::Value>(&existing) {
let hooks = serde_json::json!({
"UserPromptSubmit": [{
"hooks": [{"type": "command", "command": ".canon/hooks/prompt.sh", "timeout": 5}]
}],
"PostToolUse": [{
"matcher": "Read|Write|Edit|Bash|Grep|Glob",
"hooks": [{"type": "command", "command": ".canon/hooks/post-tool.sh", "timeout": 10}]
}],
"Stop": [{
"hooks": [{"type": "command", "command": ".canon/hooks/stop.sh", "timeout": 30}]
}]
});
val.as_object_mut().unwrap().insert("hooks".to_string(), hooks);
std::fs::write(&settings_path, serde_json::to_string_pretty(&val)?)?;
println!(" Updated .claude/settings.local.json with hooks");
}
}
} else {
let settings = serde_json::json!({
"hooks": {
"UserPromptSubmit": [{
"hooks": [{"type": "command", "command": ".canon/hooks/prompt.sh", "timeout": 5}]
}],
"PostToolUse": [{
"matcher": "Read|Write|Edit|Bash|Grep|Glob",
"hooks": [{"type": "command", "command": ".canon/hooks/post-tool.sh", "timeout": 10}]
}],
"Stop": [{
"hooks": [{"type": "command", "command": ".canon/hooks/stop.sh", "timeout": 30}]
}]
}
});
std::fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
println!(" Created .claude/settings.local.json");
}
let canon_gitignore = "graph.db\ncp.key\nhnsw.idx\n*.usearch\nsession.jsonl\n";
std::fs::write(".canon/.gitignore", canon_gitignore)?;
println!(" Created .canon/.gitignore");
let project_gitignore = PathBuf::from(".gitignore");
let canon_entries = "\n# Canon Protocol\n.canon/graph.db\n.canon/cp.key\n.canon/hnsw.idx\n.canon/*.usearch\n.canon/session.jsonl\n";
if project_gitignore.exists() {
let existing = std::fs::read_to_string(&project_gitignore)?;
if !existing.contains(".canon/graph.db") {
let mut f = std::fs::OpenOptions::new().append(true).open(&project_gitignore)?;
use std::io::Write;
f.write_all(canon_entries.as_bytes())?;
println!(" Updated .gitignore");
}
} else {
std::fs::write(&project_gitignore, canon_entries.trim_start())?;
println!(" Created .gitignore");
}
println!("\nCanon Protocol initialized. Start Claude Code and proofs will be generated automatically.");
println!("Verify proofs with: canon-mcp verify .canon/proofs/<proof>.json");
Ok(())
}
#[cfg(unix)]
fn set_executable(path: &str) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o755);
std::fs::set_permissions(path, perms)?;
Ok(())
}
#[cfg(not(unix))]
fn set_executable(_path: &str) -> Result<()> {
Ok(())
}
fn generate_session_proof(data_dir: &std::path::Path) -> Result<()> {
let session_log = data_dir.join("session.jsonl");
if !session_log.exists() {
eprintln!("No session log found at {:?}", session_log);
return Ok(());
}
let content = std::fs::read_to_string(&session_log)?;
let lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect();
if lines.is_empty() {
eprintln!("Session log is empty");
return Ok(());
}
let mut events: Vec<SessionEvent> = Vec::new();
let mut user_prompts: Vec<UserPrompt> = Vec::new();
let mut files_seen: HashMap<String, String> = HashMap::new();
for line in &lines {
if let Ok(entry) = serde_json::from_str::<serde_json::Value>(line) {
if entry.get("type").and_then(|v| v.as_str()) == Some("user_prompt") {
let prompt = entry.get("prompt").and_then(|v| v.as_str()).unwrap_or("").to_string();
let timestamp = entry.get("timestamp").and_then(|v| v.as_str()).unwrap_or("").to_string();
if !prompt.is_empty() {
user_prompts.push(UserPrompt { timestamp, prompt });
}
continue;
}
let tool = entry.get("tool_name").and_then(|v| v.as_str()).unwrap_or("unknown");
let action = match tool {
"Read" => "read",
"Write" => "write",
"Edit" => "edit",
"Bash" => "bash",
"Grep" | "Glob" => "search",
_ => "other",
};
let file_path = entry.get("tool_input")
.and_then(|v| v.get("file_path"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let timestamp = entry.get("timestamp")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let input_preview = entry.get("input_preview")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let output_preview = entry.get("output_preview")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
events.push(SessionEvent {
timestamp,
tool: tool.to_string(),
action: action.to_string(),
file_path: file_path.clone(),
input_preview,
output_preview,
});
if let Some(path) = file_path {
let existing = files_seen.get(&path).map(|s| s.as_str());
let should_update = match (existing, action) {
(None, _) => true,
(Some("read"), "write" | "edit") => true,
(Some("edit"), "write") => true,
_ => false,
};
if should_update {
files_seen.insert(path, action.to_string());
}
}
}
}
let mut files_read: Vec<FileSnapshot> = Vec::new();
let mut files_written: Vec<FileSnapshot> = Vec::new();
for (path, action) in &files_seen {
let file_path = PathBuf::from(path);
if !file_path.exists() {
continue;
}
let file_content = match std::fs::read(&file_path) {
Ok(c) => c,
Err(_) => continue,
};
let content_hash = *blake3::hash(&file_content).as_bytes();
let size_bytes = file_content.len() as u64;
let snippet = String::from_utf8_lossy(&file_content[..file_content.len().min(500)]).to_string();
let snapshot = FileSnapshot {
path: path.clone(),
content_hash,
action: action.clone(),
size_bytes,
snippet,
};
match action.as_str() {
"write" | "edit" => files_written.push(snapshot),
_ => files_read.push(snapshot),
}
}
let all_files: Vec<FileSnapshot> = files_read.iter()
.chain(files_written.iter())
.cloned()
.collect();
let files_root = proof::compute_files_root(&all_files);
let identity = load_or_create_identity(data_dir)?;
let git_context = detect_git_context();
let session_id = content.lines().next()
.and_then(|l| serde_json::from_str::<serde_json::Value>(l).ok())
.and_then(|v| v.get("session_id").and_then(|s| s.as_str()).map(|s| s.to_string()))
.unwrap_or_else(|| "unknown".to_string());
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let timestamp = server::format_timestamp(now.as_secs());
let mut proof = SessionProof {
version: 3,
session_id,
timestamp: timestamp.clone(),
user_prompts,
semantic_context: Vec::new(), events,
files_read,
files_written,
files_root,
signature: [0u8; 64],
signer_public_key: identity.public_key,
device_id: identity.device_id,
git: git_context,
};
let sig = identity.sign(&proof.signing_bytes());
proof.signature = sig;
std::fs::create_dir_all(data_dir.join("proofs"))?;
let root_prefix: String = proof.files_root.iter().take(4).map(|b| format!("{:02x}", b)).collect();
let ts_safe = timestamp.replace(':', "-").replace('.', "-");
let filename = format!("session_{}_{}.json", ts_safe, root_prefix);
let path = data_dir.join("proofs").join(&filename);
let json = serde_json::to_string_pretty(&proof)?;
std::fs::write(&path, &json)?;
std::fs::write(&session_log, "")?;
eprintln!("Session proof saved: {} ({} prompts, {} events, {} files)",
path.display(),
proof.user_prompts.len(),
proof.events.len(),
proof.files_read.len() + proof.files_written.len());
Ok(())
}
fn verify_proof(path: &std::path::Path) -> Result<()> {
let json = std::fs::read_to_string(path)
.context("Failed to read proof file")?;
if let Ok(proof) = serde_json::from_str::<SessionProof>(&json) {
verify_session_proof(&proof);
} else if let Ok(proof) = serde_json::from_str::<canon_core::ProofReceipt>(&json) {
verify_search_proof(&proof);
} else {
anyhow::bail!("Unrecognized proof format");
}
Ok(())
}
fn verify_session_proof(proof: &SessionProof) {
println!("╔══════════════════════════════════════════════════════════════╗");
println!("║ CANON PROTOCOL — SESSION AUDIT TRAIL ║");
println!("╚══════════════════════════════════════════════════════════════╝");
println!();
println!(" Session: {}", &proof.session_id[..12.min(proof.session_id.len())]);
println!(" Timestamp: {}", proof.timestamp);
println!(" Events: {}", proof.events.len());
println!(" Prompts: {}", proof.user_prompts.len());
println!("\n┌─ Cryptographic Verification ─────────────────────────────────┐");
match proof.verify_signature() {
Ok(()) => println!("│ [PASS] Ed25519 signature valid │"),
Err(e) => println!("│ [FAIL] Ed25519 signature: {}│", pad_right(&format!("{}", e), 30)),
}
match proof.verify_files_root() {
Ok(()) => println!("│ [PASS] Files Merkle root valid │"),
Err(e) => println!("│ [FAIL] Files root: {}│", pad_right(&format!("{}", e), 37)),
}
if proof.git.is_some() {
println!("│ [PASS] Git context cryptographically bound │");
}
if !proof.user_prompts.is_empty() {
println!("│ [PASS] User prompts cryptographically bound │");
}
match proof.verify_all() {
Ok(()) => {
println!("│ │");
println!("│ Result: ALL CHECKS PASSED │");
}
Err(e) => {
println!("│ │");
println!("│ Result: VERIFICATION FAILED - {}│", pad_right(&format!("{}", e), 25));
}
}
println!("└──────────────────────────────────────────────────────────────┘");
if let Some(ref gc) = proof.git {
println!("\n┌─ Git Context (signed) ──────────────────────────────────────┐");
if let Some(ref author) = gc.author { println!("│ Author: {}", author); }
if let Some(ref commit) = gc.commit { println!("│ Commit: {}", commit); }
if let Some(ref branch) = gc.branch { println!("│ Branch: {}", branch); }
println!("│ Dirty: {}", gc.dirty);
println!("└──────────────────────────────────────────────────────────────┘");
}
if !proof.user_prompts.is_empty() {
println!("\n════════════════════════════════════════════════════════════════");
println!(" WHAT THE USER ASKED ({} prompt{}):",
proof.user_prompts.len(),
if proof.user_prompts.len() == 1 { "" } else { "s" });
println!("════════════════════════════════════════════════════════════════");
for (i, p) in proof.user_prompts.iter().enumerate() {
println!("\n [Prompt {} — {}]", i + 1, p.timestamp);
println!(" > {}", p.prompt.replace('\n', "\n > "));
}
}
println!("\n════════════════════════════════════════════════════════════════");
println!(" TOOL CALLS ({} events — hook-captured ground truth):",
proof.events.len());
println!("════════════════════════════════════════════════════════════════");
for (i, event) in proof.events.iter().enumerate() {
let file = event.file_path.as_deref().unwrap_or("");
let file_short = if file.len() > 60 {
format!("...{}", &file[file.len()-57..])
} else {
file.to_string()
};
match event.action.as_str() {
"read" => {
println!("\n {}. READ {}", i + 1, file_short);
if let Some(ref preview) = event.output_preview {
let lines: Vec<&str> = preview.lines().take(8).collect();
println!(" ┌─ Content the AI received ─────────────────");
for line in &lines {
println!(" │ {}", truncate_line(line, 60));
}
if preview.lines().count() > 8 {
println!(" │ ... ({} more lines)", preview.lines().count() - 8);
}
println!(" └─────────────────────────────────────────────");
}
}
"write" => {
println!("\n {}. WROTE {}", i + 1, file_short);
if let Some(ref preview) = event.input_preview {
let lines: Vec<&str> = preview.lines().take(8).collect();
println!(" ┌─ Content the AI wrote ────────────────────");
for line in &lines {
println!(" │ {}", truncate_line(line, 60));
}
if preview.lines().count() > 8 {
println!(" │ ... ({} more lines)", preview.lines().count() - 8);
}
println!(" └─────────────────────────────────────────────");
}
}
"edit" => {
println!("\n {}. EDITED {}", i + 1, file_short);
if let Some(ref preview) = event.input_preview {
let lines: Vec<&str> = preview.lines().take(10).collect();
println!(" ┌─ Diff ──────────────────────────────────────");
for line in &lines {
println!(" │ {}", truncate_line(line, 60));
}
println!(" └─────────────────────────────────────────────");
}
}
"bash" => {
let cmd = event.input_preview.as_deref().unwrap_or("(unknown command)");
println!("\n {}. RAN $ {}", i + 1, truncate_line(cmd, 60));
if let Some(ref preview) = event.output_preview {
let lines: Vec<&str> = preview.lines().take(5).collect();
if !lines.is_empty() && !lines[0].is_empty() {
println!(" ┌─ Output ────────────────────────────────────");
for line in &lines {
println!(" │ {}", truncate_line(line, 60));
}
if preview.lines().count() > 5 {
println!(" │ ... ({} more lines)", preview.lines().count() - 5);
}
println!(" └─────────────────────────────────────────────");
}
}
}
"search" => {
let pattern = event.input_preview.as_deref().unwrap_or("(unknown)");
println!("\n {}. SEARCHED for \"{}\"", i + 1, pattern);
if let Some(ref preview) = event.output_preview {
let lines: Vec<&str> = preview.lines().take(5).collect();
if !lines.is_empty() {
println!(" ┌─ Results ───────────────────────────────────");
for line in &lines {
println!(" │ {}", truncate_line(line, 60));
}
if preview.lines().count() > 5 {
println!(" │ ... ({} more)", preview.lines().count() - 5);
}
println!(" └─────────────────────────────────────────────");
}
}
}
_ => {
println!("\n {}. [{}] {} → {}", i + 1, event.action, event.tool, file_short);
}
}
}
if !proof.files_read.is_empty() {
println!("\n════════════════════════════════════════════════════════════════");
println!(" FILE SNAPSHOTS — READ ({}):", proof.files_read.len());
println!("════════════════════════════════════════════════════════════════");
for f in &proof.files_read {
let hash_hex: String = f.content_hash.iter().take(4).map(|b| format!("{:02x}", b)).collect();
println!("\n {} ({} bytes, BLAKE3: {})", f.path, f.size_bytes, hash_hex);
println!(" ┌────────────────────────────────────────────────────────────");
for line in f.snippet.lines().take(12) {
println!(" │ {}", truncate_line(line, 60));
}
if f.snippet.lines().count() > 12 || f.size_bytes > 500 {
println!(" │ ... ({} total bytes)", f.size_bytes);
}
println!(" └────────────────────────────────────────────────────────────");
}
}
if !proof.files_written.is_empty() {
println!("\n════════════════════════════════════════════════════════════════");
println!(" FILE SNAPSHOTS — WRITTEN/EDITED ({}):", proof.files_written.len());
println!("════════════════════════════════════════════════════════════════");
for f in &proof.files_written {
let hash_hex: String = f.content_hash.iter().take(4).map(|b| format!("{:02x}", b)).collect();
println!("\n {} ({} bytes, BLAKE3: {})", f.path, f.size_bytes, hash_hex);
println!(" ┌────────────────────────────────────────────────────────────");
for line in f.snippet.lines().take(12) {
println!(" │ {}", truncate_line(line, 60));
}
if f.snippet.lines().count() > 12 || f.size_bytes > 500 {
println!(" │ ... ({} total bytes)", f.size_bytes);
}
println!(" └────────────────────────────────────────────────────────────");
}
}
if proof.verify_all().is_err() {
std::process::exit(1);
}
}
fn truncate_line(s: &str, max: usize) -> &str {
if s.len() <= max { s } else { &s[..max] }
}
fn pad_right(s: &str, width: usize) -> String {
if s.len() >= width { s.to_string() } else { format!("{}{}", s, " ".repeat(width - s.len())) }
}
fn verify_search_proof(proof: &canon_core::ProofReceipt) {
println!("Search Proof Receipt v{}", proof.version);
println!(" Query: {}", proof.query);
println!(" Timestamp: {}", proof.timestamp);
println!("\nVerification:");
match proof.verify_signature() {
Ok(()) => println!(" [PASS] Ed25519 signature valid"),
Err(e) => println!(" [FAIL] Ed25519 signature: {}", e),
}
match proof.verify_chunk_proofs() {
Ok(()) => println!(" [PASS] Merkle proofs valid ({}/{} chunks)",
proof.chunk_proofs.len(), proof.chunk_proofs.len()),
Err(e) => println!(" [FAIL] Merkle proofs: {}", e),
}
if proof.git.is_some() {
println!(" [PASS] Git context cryptographically bound");
}
match proof.verify_all() {
Ok(()) => println!("\nResult: ALL CHECKS PASSED"),
Err(e) => {
println!("\nResult: VERIFICATION FAILED - {}", e);
std::process::exit(1);
}
}
if let Some(ref gc) = proof.git {
println!("\nGit Context (signed):");
if let Some(ref author) = gc.author { println!(" Author: {}", author); }
if let Some(ref commit) = gc.commit { println!(" Commit: {}", commit); }
if let Some(ref branch) = gc.branch { println!(" Branch: {}", branch); }
println!(" Dirty: {}", gc.dirty);
}
println!("\n════════════════════════════════════════");
println!("What Canon served ({} chunks):", proof.sources.len());
println!("════════════════════════════════════════");
for (i, src) in proof.sources.iter().enumerate() {
println!("\n--- Chunk {} of {} ---", i + 1, proof.sources.len());
println!("Source: {} (sequence {})", src.document_path, src.chunk_sequence);
println!("Relevance: {:.3}", src.relevance_score);
println!("Content:\n{}", src.chunk_text.trim());
}
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Some(Commands::Init) => {
init_project()
}
Some(Commands::SessionProof { data_dir }) => {
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_env_filter("warn")
.init();
std::fs::create_dir_all(&data_dir)?;
generate_session_proof(&data_dir)
}
Some(Commands::Verify { path }) => {
verify_proof(&path)
}
None => {
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env()
.add_directive("canon_mcp=info".parse()?)
.add_directive(tracing::Level::WARN.into()),
)
.init();
std::fs::create_dir_all(&cli.data_dir)
.context("Failed to create data directory")?;
ensure_gitignore(&cli.data_dir)?;
let db_path = cli.data_dir.join("graph.db");
let graph = GraphStore::open(db_path.to_str().unwrap())
.context("Failed to open graph store")?;
let graph = Arc::new(Mutex::new(graph));
let identity = load_or_create_identity(&cli.data_dir)
.context("Failed to load device identity")?;
info!(
"Device identity: {}",
identity.device_id.iter().map(|b| format!("{:02x}", b)).collect::<String>()
);
let embedder = Arc::new(EmbeddingEngine::new().context("Failed to initialize embedding engine")?);
let query_engine = Arc::new(crate::search::QueryEngine::new(graph.clone(), embedder.clone()));
let git_context = detect_git_context();
if let Some(ref gc) = git_context {
if let Some(ref commit) = gc.commit {
info!("Git context: commit={}, branch={:?}", &commit[..8.min(commit.len())], gc.branch);
}
}
let _watcher_handle = if let Some(watch_dir) = cli.watch {
let watch_path = watch_dir.canonicalize().unwrap_or(watch_dir);
info!("Watching directory: {:?}", watch_path);
let handle = watcher::start_watcher(
watch_path,
graph.clone(),
embedder.clone(),
cli.data_dir.clone(),
)?;
Some(handle)
} else {
None
};
std::fs::create_dir_all(cli.data_dir.join("proofs"))?;
let canon_server = server::CanonServer::new(
graph,
query_engine,
embedder,
identity,
cli.data_dir,
git_context,
);
info!("Starting Canon Protocol MCP server on stdio...");
canon_server.run_stdio().await?;
Ok(())
}
}
}