1use rmcp::ErrorData;
3use rmcp::model::Tool;
4use serde_json::{Map, Value, json};
5
6use crate::server::tool_trait::{
7 McpTool, ToolContext, ToolOutput, get_bool, get_str, get_str_array,
8};
9use lean_ctx::core::handoff_ledger::{HandoffLedgerV1, LedgerLineage, LedgerPart};
10use lean_ctx::core::session::SessionState;
11use lean_ctx::tool_defs::tool_def;
12
13pub struct CtxHandoffTool;
14
15impl McpTool for CtxHandoffTool {
16 fn name(&self) -> &'static str {
17 "ctx_handoff"
18 }
19
20 fn tool_def(&self) -> Tool {
21 tool_def(
22 "ctx_handoff",
23 "Context handoff protocol (hashed, deterministic, local-first).\n\
24 Actions: create|show|list|pull|merge|clear|export|import. Stores curated file refs with hashes.\n\
25 Before ending a session or handing off to another agent; merge combines ledgers of parallel agents.",
26 json!({
27 "type": "object",
28 "properties": {
29 "action": {
30 "type": "string",
31 "enum": ["create", "show", "list", "pull", "merge", "clear", "export", "import"],
32 "description": "create|show|list|pull|merge|clear|export|import"
33 },
34 "ledger": { "type": "string", "description": "Stored ledger to export (action=export), e.g. a merged one; default: a new ledger of this session" },
35 "ledgers": { "type": "array", "items": { "type": "string" }, "description": "Ledger file paths to merge (action=merge, 2-8)" },
36 "path": { "type": "string", "description": "Ledger file path (for show/pull/import)" },
37 "paths": { "type": "array", "items": { "type": "string" }, "description": "File paths for curated refs" },
38 "format": { "type": "string", "description": "json|summary|a2a" },
39 "write": { "type": "boolean", "description": "Write export to file" },
40 "privacy": { "type": "string", "description": "redacted|full (admin only)" },
41 "filename": { "type": "string", "description": "Custom filename for export" },
42 "apply_workflow": { "type": "boolean", "description": "Apply workflow state" },
43 "apply_session": { "type": "boolean", "description": "Apply session snapshot" },
44 "apply_knowledge": { "type": "boolean", "description": "Import knowledge facts" },
45 "request_id": { "type": "string", "maxLength": 128, "description": "create: idempotency key; a retry with the same key and paths returns the first ledger, other paths are refused" }
46 },
47 "allOf": [
48 {
49 "if": { "properties": { "action": { "const": "show" } }, "required": ["action"] },
50 "then": { "required": ["action", "path"] }
51 },
52 {
53 "if": { "properties": { "action": { "const": "pull" } }, "required": ["action"] },
54 "then": { "required": ["action", "path"] }
55 },
56 {
57 "if": { "properties": { "action": { "const": "import" } }, "required": ["action"] },
58 "then": { "required": ["action", "path"] }
59 },
60 {
61 "if": { "properties": { "action": { "const": "merge" } }, "required": ["action"] },
62 "then": { "required": ["action", "ledgers"] }
63 }
64 ]
65 }),
66 )
67 }
68
69 fn handle(
70 &self,
71 args: &Map<String, Value>,
72 ctx: &ToolContext,
73 ) -> Result<ToolOutput, ErrorData> {
74 let action = get_str(args, "action").unwrap_or_else(|| "list".to_string());
75 let result = match action.as_str() {
76 "list" => handle_list(&ctx.project_root),
77 "clear" => handle_clear(&ctx.project_root),
78 "show" => handle_show(args, ctx)?,
79 "create" => handle_create(args, ctx)?,
80 "export" => handle_export(args, ctx)?,
81 "pull" => handle_pull(args, ctx)?,
82 "import" => handle_import(args, ctx)?,
83 "merge" => handle_merge(args, ctx)?,
84 _ => "Unknown action. Use: create, show, list, pull, merge, clear, export, import"
85 .to_string(),
86 };
87
88 Ok(ToolOutput {
89 text: result,
90 original_tokens: 0,
91 saved_tokens: 0,
92 mode: Some(action),
93 path: None,
94 changed: false,
95 shell_outcome: None,
96 content_blocks: None,
97 })
98 }
99
100 fn publishes_every_source(&self, args: Option<&Map<String, Value>>) -> bool {
112 args.and_then(|args| get_str(args, "action")).as_deref() == Some("show")
113 }
114}
115
116fn handle_list(project_root: &str) -> String {
117 let items = lean_ctx::core::handoff_ledger::list_ledgers(project_root);
118 crate::tools::ctx_handoff::format_list(&items)
119}
120
121fn handle_clear(project_root: &str) -> String {
122 let removed = lean_ctx::core::handoff_ledger::clear_ledgers(project_root).unwrap_or_default();
123 crate::tools::ctx_handoff::format_clear(removed)
124}
125
126fn handle_show(args: &Map<String, Value>, ctx: &ToolContext) -> Result<String, ErrorData> {
127 let path = get_str(args, "path")
128 .ok_or_else(|| ErrorData::invalid_params("path is required for action=show", None))?;
129 let path = ctx
130 .resolve_path_sync(&path)
131 .map_err(|e| ErrorData::invalid_params(e, None))?;
132 let ledger = lean_ctx::core::handoff_ledger::load_ledger(std::path::Path::new(&path))
133 .map_err(|e| ErrorData::internal_error(format!("load ledger: {e}"), None))?;
134 Ok(crate::tools::ctx_handoff::format_show(
135 std::path::Path::new(&path),
136 &ledger,
137 ))
138}
139
140fn handle_merge(args: &Map<String, Value>, ctx: &ToolContext) -> Result<String, ErrorData> {
143 let ledgers = get_str_array(args, "ledgers").unwrap_or_default();
144 if ledgers.len() > lean_ctx::core::handoff_ledger::MAX_MERGE_INPUTS {
145 return Err(ErrorData::invalid_params(
146 format!(
147 "ledgers: at most {} paths",
148 lean_ctx::core::handoff_ledger::MAX_MERGE_INPUTS
149 ),
150 None,
151 ));
152 }
153 let paths = ledgers
154 .iter()
155 .map(|path| ctx.resolve_path_sync(path).map(std::path::PathBuf::from))
156 .collect::<Result<Vec<_>, _>>()
157 .map_err(|e| ErrorData::invalid_params(e, None))?;
158 match lean_ctx::core::handoff_ledger::merge_ledgers(&paths) {
159 Ok(outcome) => Ok(crate::tools::ctx_handoff::format_merged(&outcome)),
160 Err(e) => Ok(format!("ctx_handoff merge\n refused: {e}")),
161 }
162}
163
164fn resolve_curated_refs(
165 args: &Map<String, Value>,
166 ctx: &ToolContext,
167) -> Result<Vec<(String, String)>, ErrorData> {
168 let curated_paths = get_str_array(args, "paths").unwrap_or_default();
169 let mut curated_refs: Vec<(String, String)> = Vec::new();
170 if curated_paths.is_empty() {
171 return Ok(curated_refs);
172 }
173
174 let mut resolved: Vec<String> = Vec::new();
175 for p in curated_paths.into_iter().take(20) {
176 let abs = ctx
177 .resolve_path_sync(&p)
178 .map_err(|e| ErrorData::invalid_params(e, None))?;
179 resolved.push(abs);
180 }
181
182 let cache_handle = ctx
183 .cache
184 .as_ref()
185 .ok_or_else(|| ErrorData::internal_error("cache not available", None))?;
186 let Some(mut cache) = crate::server::bounded_lock::write(cache_handle, "ctx_handoff") else {
187 return Err(ErrorData::internal_error(
188 "cache busy (ctx_handoff) — retry in a moment",
189 None,
190 ));
191 };
192 for abs in &resolved {
193 let mode = if crate::tools::ctx_read::is_instruction_file(abs) {
194 "full"
195 } else {
196 "signatures"
197 };
198 let text =
199 crate::tools::ctx_read::handle_with_task(&mut cache, abs, mode, ctx.crp_mode, None);
200 curated_refs.push((abs.clone(), text));
201 }
202
203 Ok(curated_refs)
204}
205
206fn handle_create(args: &Map<String, Value>, ctx: &ToolContext) -> Result<String, ErrorData> {
207 let curated_refs = resolve_curated_refs(args, ctx)?;
208
209 let session_handle = ctx
210 .session
211 .as_ref()
212 .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
213 let session = { session_handle.blocking_read().clone() };
214 let active_intent = session.active_structured_intent.clone();
215
216 let tool_calls = ctx
217 .tool_calls
218 .as_ref()
219 .map(|tc| tc.blocking_read().clone())
220 .unwrap_or_default();
221 let workflow = ctx
222 .workflow
223 .as_ref()
224 .map(|w| w.blocking_read().clone())
225 .unwrap_or_default();
226 let agent_id = ctx
227 .agent_id
228 .as_ref()
229 .map(|a| a.blocking_read().clone())
230 .unwrap_or_default();
231 let client_name = ctx
232 .client_name
233 .as_ref()
234 .map(|c| c.blocking_read().clone())
235 .unwrap_or_default();
236 let project_root = session.project_root.clone();
237
238 let (ledger, path) = lean_ctx::core::handoff_ledger::create_ledger(
239 lean_ctx::core::handoff_ledger::CreateLedgerInput {
240 agent_id,
241 client_name: Some(client_name),
242 project_root,
243 session,
244 tool_calls,
245 workflow,
246 curated_refs,
247 request_id: get_str(args, "request_id"),
248 },
249 )
250 .map_err(|e| ErrorData::internal_error(format!("create ledger: {e}"), None))?;
251
252 let ctx_ledger_handle = ctx
253 .ledger
254 .as_ref()
255 .ok_or_else(|| ErrorData::internal_error("ledger not available", None))?;
256 let ctx_ledger = ctx_ledger_handle.blocking_read();
257 let package = lean_ctx::core::handoff_ledger::HandoffPackage::build(
258 ledger.clone(),
259 active_intent.as_ref(),
260 if ctx_ledger.entries.is_empty() {
261 None
262 } else {
263 Some(&*ctx_ledger)
264 },
265 );
266 drop(ctx_ledger);
267
268 let mut output = crate::tools::ctx_handoff::format_created(&path, &ledger);
269 let compact = package.format_compact();
270 if !compact.is_empty() {
271 output.push_str("\n\n");
272 output.push_str(&compact);
273 }
274
275 Ok(output)
276}
277
278fn handle_export(args: &Map<String, Value>, ctx: &ToolContext) -> Result<String, ErrorData> {
279 let (ledger, project_root) = match get_str(args, "ledger") {
280 Some(stored) => {
282 let stored = ctx
283 .resolve_path_sync(&stored)
284 .map_err(|e| ErrorData::invalid_params(e, None))?;
285 let ledger = lean_ctx::core::handoff_ledger::load_ledger(std::path::Path::new(&stored))
286 .map_err(|e| ErrorData::internal_error(format!("load ledger: {e}"), None))?;
287 let project_root = ledger.project_root.clone();
288 (ledger, project_root)
289 }
290 None => export_new_ledger(args, ctx)?,
291 };
292 export_ledger(args, ctx, ledger, project_root.as_deref())
293}
294
295fn export_new_ledger(
297 args: &Map<String, Value>,
298 ctx: &ToolContext,
299) -> Result<(HandoffLedgerV1, Option<String>), ErrorData> {
300 let curated_refs = resolve_curated_refs(args, ctx)?;
301
302 let session_handle = ctx
303 .session
304 .as_ref()
305 .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
306 let session = { session_handle.blocking_read().clone() };
307
308 let tool_calls = ctx
309 .tool_calls
310 .as_ref()
311 .map(|tc| tc.blocking_read().clone())
312 .unwrap_or_default();
313 let workflow = ctx
314 .workflow
315 .as_ref()
316 .map(|w| w.blocking_read().clone())
317 .unwrap_or_default();
318 let agent_id = ctx
319 .agent_id
320 .as_ref()
321 .map(|a| a.blocking_read().clone())
322 .unwrap_or_default();
323 let client_name = ctx
324 .client_name
325 .as_ref()
326 .map(|c| c.blocking_read().clone())
327 .unwrap_or_default();
328 let project_root = session.project_root.clone();
329
330 let (ledger, _ledger_path) = lean_ctx::core::handoff_ledger::create_ledger(
331 lean_ctx::core::handoff_ledger::CreateLedgerInput {
332 agent_id,
333 client_name: Some(client_name),
334 project_root: project_root.clone(),
335 session,
336 tool_calls,
337 workflow,
338 curated_refs,
339 request_id: None,
340 },
341 )
342 .map_err(|e| ErrorData::internal_error(format!("create ledger: {e}"), None))?;
343 Ok((ledger, project_root))
344}
345
346fn export_ledger(
347 args: &Map<String, Value>,
348 ctx: &ToolContext,
349 ledger: HandoffLedgerV1,
350 project_root: Option<&str>,
351) -> Result<String, ErrorData> {
352 let privacy = lean_ctx::core::handoff_transfer_bundle::BundlePrivacyV1::parse(
353 get_str(args, "privacy").as_deref(),
354 );
355 if privacy == lean_ctx::core::handoff_transfer_bundle::BundlePrivacyV1::Full
356 && lean_ctx::core::roles::active_role_name() != "admin"
357 {
358 return Ok("ERROR: privacy=full requires role 'admin'.".to_string());
359 }
360
361 let mut bundle =
362 lean_ctx::core::handoff_transfer_bundle::build_bundle_v1(ledger, project_root, privacy);
363 let signer_id = ctx
367 .agent_id
368 .as_ref()
369 .and_then(|a| a.blocking_read().clone())
370 .filter(|id| !id.trim().is_empty())
371 .unwrap_or_else(|| lean_ctx::core::agent_identity::current_agent_id().to_string());
372 let sign_warning =
373 lean_ctx::core::handoff_transfer_bundle::sign_bundle(&mut bundle, &signer_id)
374 .err()
375 .map(|e| format!("WARNING: bundle not signed: {e}"));
376 let json = lean_ctx::core::handoff_transfer_bundle::serialize_bundle_v1_pretty(&bundle)
377 .map_err(|e| ErrorData::internal_error(e, None))?;
378
379 let write = get_bool(args, "write").unwrap_or(false);
380 let format = get_str(args, "format").unwrap_or_else(|| {
381 if write || get_str(args, "path").is_some() || get_str(args, "filename").is_some() {
382 "summary".to_string()
383 } else {
384 "json".to_string()
385 }
386 });
387
388 let root = project_root.map(str::to_owned).unwrap_or_else(|| {
389 std::env::current_dir()
390 .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string())
391 });
392 let root_path = std::path::PathBuf::from(&root);
393
394 let mut written: Option<std::path::PathBuf> = None;
395 if write || get_str(args, "path").is_some() || get_str(args, "filename").is_some() {
396 let ts = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
397 let candidate = if let Some(p) = get_str(args, "path") {
398 let p = std::path::PathBuf::from(p);
399 if p.is_absolute() {
400 p
401 } else {
402 root_path.join(p)
403 }
404 } else if let Some(name) = get_str(args, "filename") {
405 root_path.join(".lean-ctx").join("proofs").join(name)
406 } else {
407 let session_id = bundle.ledger.session.id.clone();
408 root_path
409 .join(".lean-ctx")
410 .join("proofs")
411 .join(format!("handoff-transfer-bundle-v1_{session_id}_{ts}.json"))
412 };
413
414 let jailed = match lean_ctx::core::io_boundary::jail_and_check_path(
415 "ctx_handoff.export",
416 candidate.as_path(),
417 root_path.as_path(),
418 ) {
419 Ok((p, _warning)) => p,
420 Err(e) => return Ok(e),
421 };
422
423 if let Err(e) = lean_ctx::core::pathjail::enforce_writable(&jailed) {
426 return Ok(format!("Export write failed: {e}"));
427 }
428 if let Err(e) = lean_ctx::core::handoff_transfer_bundle::write_bundle_v1(
429 &root_path, &candidate, &jailed, &json,
430 ) {
431 return Ok(format!("Export write failed: {e}"));
432 }
433
434 written = Some(jailed);
435 }
436
437 let out = match format.as_str() {
438 "summary" => {
442 let mut s = crate::tools::ctx_handoff::format_exported(
443 written.as_deref(),
444 bundle.schema_version,
445 json.len(),
446 &bundle.privacy,
447 );
448 match sign_warning.as_deref() {
449 Some(w) => {
450 s.push('\n');
451 s.push_str(w);
452 }
453 None => {
454 s.push_str(&format!("\n signed_by: {signer_id}"));
455 }
456 }
457 s
458 }
459 "a2a" => {
462 let task = lean_ctx::core::a2a::transfer::wrap_bundle_as_a2a_task(&bundle)
463 .map_err(|e| ErrorData::internal_error(e, None))?;
464 serde_json::to_string_pretty(&task)
465 .map_err(|e| ErrorData::internal_error(format!("a2a serialization: {e}"), None))?
466 }
467 _ => {
468 if let Some(p) = written.as_deref() {
469 format!("{json}\n\npath: {}", p.display())
470 } else {
471 json
472 }
473 }
474 };
475
476 Ok(out)
477}
478
479fn handle_pull(args: &Map<String, Value>, ctx: &ToolContext) -> Result<String, ErrorData> {
480 let path = get_str(args, "path")
481 .ok_or_else(|| ErrorData::invalid_params("path is required for action=pull", None))?;
482 let path = ctx
483 .resolve_path_sync(&path)
484 .map_err(|e| ErrorData::invalid_params(e, None))?;
485 let (ledger, lineage) =
488 lean_ctx::core::handoff_ledger::load_ledger_bound(std::path::Path::new(&path))
489 .map_err(|e| ErrorData::internal_error(format!("load ledger: {e}"), None))?;
490 let incoming = Incoming::Ledger(lineage.as_ref());
491
492 let apply_workflow = get_bool(args, "apply_workflow").unwrap_or(true);
493 let apply_session = get_bool(args, "apply_session").unwrap_or(true);
494 let apply_knowledge = get_bool(args, "apply_knowledge").unwrap_or(true);
495
496 if apply_workflow && let Some(wf_lock) = ctx.workflow.as_ref() {
497 let mut wf = wf_lock.blocking_write();
498 if ledger
499 .workflow
500 .as_ref()
501 .is_some_and(|r| r.current == "done")
502 {
503 *wf = None;
504 } else {
505 wf.clone_from(&ledger.workflow);
506 }
507 }
508
509 let mut skipped = 0usize;
510 if apply_session {
511 let session_handle = ctx
512 .session
513 .as_ref()
514 .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
515 let mut session = session_handle.blocking_write();
516 skipped += restore_session(&mut session, &ledger, incoming);
517 let _ = session.save();
518 }
519
520 let (knowledge_imported, contradictions) = if apply_knowledge {
521 let (imported, contradictions, withheld) =
522 import_knowledge_from_ledger(ctx, &ledger, incoming)?;
523 skipped += withheld;
524 (imported, contradictions)
525 } else {
526 (0, 0)
527 };
528
529 let lines = [
530 "ctx_handoff pull".to_string(),
531 format!(" path: {path}"),
532 format!(" md5: {}", ledger.content_md5),
533 format!(" applied_workflow: {apply_workflow}"),
534 format!(" applied_session: {apply_session}"),
535 format!(" imported_knowledge: {knowledge_imported}"),
536 format!(" contradictions: {contradictions}"),
537 format!(" skipped_withheld: {skipped}"),
538 ];
539 Ok(lines.join("\n"))
540}
541
542#[derive(Clone, Copy)]
544enum Incoming<'a> {
545 Ledger(Option<&'a LedgerLineage>),
551 Bundle,
556}
557
558impl<'a> Incoming<'a> {
559 fn storable(self, text: &str) -> Option<String> {
563 match self {
564 Self::Ledger(_) => Some(text.to_owned()),
565 Self::Bundle => lean_ctx::core::context_admission::carrier::admit_for_store(text).ok(),
566 }
567 }
568
569 fn lineage(self) -> Option<&'a LedgerLineage> {
570 match self {
571 Self::Ledger(lineage) => lineage,
572 Self::Bundle => None,
573 }
574 }
575}
576
577fn restore_session(
580 session: &mut SessionState,
581 ledger: &HandoffLedgerV1,
582 incoming: Incoming<'_>,
583) -> usize {
584 let excerpt = &ledger.session;
585 let mut skipped = 0usize;
586 if let Some(task) = excerpt.task.as_deref() {
587 if let Some(task) = incoming.storable(task) {
588 session.set_task(&task, None);
589 } else {
590 skipped += 1;
591 }
592 }
593 for decision in &excerpt.decisions {
594 if let Some(decision) = incoming.storable(decision) {
595 session.add_decision(&decision, None);
596 } else {
597 skipped += 1;
598 }
599 }
600 for (index, finding) in excerpt.findings.iter().enumerate() {
601 let lineage = incoming.lineage().map(|lineage| (lineage, index));
603 let stored = incoming
604 .storable(finding)
605 .is_some_and(|finding| restore_finding(session, &finding, lineage));
606 if !stored {
607 skipped += 1;
608 }
609 }
610 let next_steps: Vec<String> = excerpt
611 .next_steps
612 .iter()
613 .filter_map(|step| incoming.storable(step))
614 .collect();
615 skipped += excerpt.next_steps.len() - next_steps.len();
616 session.next_steps = next_steps;
617 skipped
618}
619
620fn restore_finding(
632 session: &mut SessionState,
633 summary: &str,
634 lineage: Option<(&LedgerLineage, usize)>,
635) -> bool {
636 use lean_ctx::core::archive::bindings::Item;
637 let Some((lineage, index)) = lineage else {
638 session.add_finding(None, None, summary);
639 return true;
640 };
641 let id = session.id.clone();
642 let item = Item::SessionFinding {
643 session: &id,
644 file: None,
645 };
646 let Ok(carried) = lineage.carry(&LedgerPart::Finding(summary), index, &item) else {
647 return false;
648 };
649 session.add_finding(None, None, summary);
650 let Some(carried) = carried else {
651 return true;
652 };
653 let kept = session
654 .findings
655 .last()
656 .is_some_and(|finding| finding.summary == summary);
657 if (kept && carried.commit(&item, summary))
658 || !lean_ctx::core::context_admission::stored::source_authority_required()
659 {
660 return true;
661 }
662 session.findings.pop();
663 false
664}
665
666const HANDOFF_UNVERIFIED_HINT: &str = "The user can pin the signer key for this agent id in \
668 trusted-signers.toml, or set `mcp_import_allow_unverified = true` in the global config. \
669 A tool argument cannot grant this.";
670
671fn import_unverified_allowed() -> bool {
673 lean_ctx::core::config::Config::load().mcp_import_allow_unverified
674}
675
676fn handle_import(args: &Map<String, Value>, ctx: &ToolContext) -> Result<String, ErrorData> {
677 let path = get_str(args, "path")
678 .ok_or_else(|| ErrorData::invalid_params("path is required for action=import", None))?;
679
680 let project_root = ctx.project_root.clone();
681 let root_path = std::path::PathBuf::from(&project_root);
682
683 let candidate = {
684 let p = std::path::PathBuf::from(&path);
685 if p.is_absolute() {
686 p
687 } else {
688 root_path.join(p)
689 }
690 };
691 let jailed = match lean_ctx::core::io_boundary::jail_and_check_path(
692 "ctx_handoff.import",
693 candidate.as_path(),
694 root_path.as_path(),
695 ) {
696 Ok((p, _warning)) => p,
697 Err(e) => return Ok(e),
698 };
699
700 let bundle = match lean_ctx::core::handoff_transfer_bundle::read_bundle_v1(&jailed) {
701 Ok(b) => b,
702 Err(e) => return Ok(format!("Import failed: {e}")),
703 };
704
705 let signature_line =
709 match lean_ctx::core::handoff_transfer_bundle::check_bundle_signature(&bundle) {
710 lean_ctx::core::handoff_transfer_bundle::BundleSignatureStatus::Invalid(reason) => {
711 lean_ctx::core::audit_trail::record(lean_ctx::core::audit_trail::AuditEntryData {
712 agent_id: bundle
713 .signer_agent_id
714 .clone()
715 .unwrap_or_else(|| "unknown".to_string()),
716 tool: "ctx_handoff".to_string(),
717 action: Some("import_signature_invalid".to_string()),
718 input_hash: lean_ctx::core::audit_trail::hash_input(args),
719 output_tokens: 0,
720 role: lean_ctx::core::roles::active_role_name(),
721 event_type: lean_ctx::core::audit_trail::AuditEventType::SecurityViolation,
722 });
723 return Ok(format!(
724 "IMPORT BLOCKED: bundle signature verification failed ({reason}).\n\
725 The bundle was modified after signing or carries broken signature material.\n\
726 Re-export it on the source agent (ctx_handoff export signs automatically)."
727 ));
728 }
729 lean_ctx::core::handoff_transfer_bundle::BundleSignatureStatus::Signed(verified) => {
734 if !verified.trusted && !import_unverified_allowed() {
735 return Ok(format!(
736 "IMPORT BLOCKED: {}.\n\
737 A valid signature from a key that is not pinned for this signer proves \
738 integrity only, not who sent the bundle.\n{HANDOFF_UNVERIFIED_HINT}",
739 verified.describe()
740 ));
741 }
742 format!(" signature: {}", verified.describe())
743 }
744 lean_ctx::core::handoff_transfer_bundle::BundleSignatureStatus::Unsigned => {
745 if !import_unverified_allowed() {
746 return Ok(format!(
747 "IMPORT BLOCKED: bundle is unsigned, so its origin and integrity \
748 cannot be checked.\n\
749 Re-export it on the source agent (ctx_handoff export signs automatically).\n\
750 {HANDOFF_UNVERIFIED_HINT}"
751 ));
752 }
753 " signature: WARNING unsigned bundle imported (mcp_import_allow_unverified)"
754 .to_string()
755 }
756 };
757
758 let warning =
759 lean_ctx::core::handoff_transfer_bundle::project_identity_warning(&bundle, &project_root);
760
761 if let Some(ref w) = warning {
762 let source_hash = bundle
763 .project
764 .project_root_hash
765 .as_deref()
766 .unwrap_or("unknown");
767 let target_hash = lean_ctx::core::project_hash::hash_project_root(&project_root);
768 let role = lean_ctx::core::roles::active_role();
769 if !role.io.allow_cross_project_search {
770 let event = lean_ctx::core::memory_boundary::CrossProjectAuditEvent {
771 timestamp: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
772 event_type: lean_ctx::core::memory_boundary::CrossProjectEventType::Import,
773 source_project_hash: source_hash.to_string(),
774 target_project_hash: target_hash,
775 tool: "ctx_handoff".to_string(),
776 action: "import".to_string(),
777 facts_accessed: 0,
778 allowed: false,
779 policy_reason: format!("identity mismatch: {w}"),
780 };
781 lean_ctx::core::memory_boundary::record_audit_event(&event);
782 return Ok(format!(
783 "IMPORT BLOCKED: project identity mismatch. {w}\n\
784 Set `io.allow_cross_project_search = true` in your role to allow cross-project imports."
785 ));
786 }
787 }
788
789 let schema_version = bundle.schema_version;
790 let ledger = bundle.ledger;
791
792 let apply_workflow = get_bool(args, "apply_workflow").unwrap_or(true);
793 let apply_session = get_bool(args, "apply_session").unwrap_or(true);
794 let apply_knowledge = get_bool(args, "apply_knowledge").unwrap_or(true);
795
796 if apply_workflow && let Some(wf_lock) = ctx.workflow.as_ref() {
797 let mut wf = wf_lock.blocking_write();
798 if ledger
799 .workflow
800 .as_ref()
801 .is_some_and(|r| r.current == "done")
802 {
803 *wf = None;
804 } else {
805 wf.clone_from(&ledger.workflow);
806 }
807 }
808
809 let mut skipped = 0usize;
813 if apply_session {
814 let session_handle = ctx
815 .session
816 .as_ref()
817 .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
818 let mut session = session_handle.blocking_write();
819 skipped += restore_session(&mut session, &ledger, Incoming::Bundle);
820 let _ = session.save();
821 }
822
823 let (knowledge_imported, contradictions) = if apply_knowledge {
824 let (imported, contradictions, withheld) =
825 import_knowledge_from_ledger(ctx, &ledger, Incoming::Bundle)?;
826 skipped += withheld;
827 (imported, contradictions)
828 } else {
829 (0, 0)
830 };
831
832 Ok(crate::tools::ctx_handoff::format_imported(
833 jailed.as_path(),
834 schema_version,
835 knowledge_imported,
836 contradictions,
837 skipped,
838 warning.as_deref(),
839 &signature_line,
840 ))
841}
842
843fn import_knowledge_from_ledger(
853 ctx: &ToolContext,
854 ledger: &HandoffLedgerV1,
855 incoming: Incoming<'_>,
856) -> Result<(u32, u32, usize), ErrorData> {
857 use lean_ctx::core::archive::bindings::Item;
858 let project_root = ctx.project_root.clone();
859 let session_id = {
860 let session_handle = ctx
861 .session
862 .as_ref()
863 .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
864 let s = session_handle.blocking_read();
865 s.id.clone()
866 };
867
868 let policy = match lean_ctx::core::config::Config::load().memory_policy_effective() {
869 Ok(p) => p,
870 Err(e) => {
871 let path = lean_ctx::core::config::Config::path().map_or_else(
872 || "~/.lean-ctx/config.toml".to_string(),
873 |p| p.display().to_string(),
874 );
875 return Err(ErrorData::internal_error(
876 format!("Error: invalid memory policy: {e}\nFix: edit {path}"),
877 None,
878 ));
879 }
880 };
881
882 let result =
885 lean_ctx::core::knowledge::ProjectKnowledge::mutate_locked(&project_root, |knowledge| {
886 let mut imported = 0u32;
887 let mut contradictions = 0u32;
888 let mut skipped = 0usize;
889 let root = knowledge.project_root.clone();
890 for (index, fact) in ledger.knowledge.facts.iter().enumerate() {
891 let Some(value) = incoming.storable(&fact.value) else {
892 skipped += 1;
893 continue;
894 };
895 let carried = match incoming.lineage() {
896 Some(lineage) => {
897 let item = Item::KnowledgeFact {
898 project: &root,
899 category: &fact.category,
900 key: &fact.key,
901 };
902 let part = LedgerPart::Fact {
903 category: &fact.category,
904 key: &fact.key,
905 value: &value,
906 };
907 lineage.carry(&part, index, &item)
908 }
909 None => Ok(None),
910 };
911 let mut contradiction = false;
912 let stored = lean_ctx::core::consolidation_engine::store_carried(
913 knowledge,
914 carried,
915 [&fact.category, &fact.key, &value],
916 false,
917 |knowledge| {
918 contradiction = knowledge
919 .remember(
920 &fact.category,
921 &fact.key,
922 &value,
923 &session_id,
924 fact.confidence,
925 &policy,
926 )
927 .is_some();
928 },
929 );
930 if !stored {
931 skipped += 1;
932 continue;
933 }
934 if contradiction {
935 contradictions += 1;
936 }
937 imported += 1;
938 }
939 (imported, contradictions, skipped)
940 });
941
942 match result {
943 Ok((_, counts)) => Ok(counts),
944 Err(e) => Err(ErrorData::internal_error(
945 format!("knowledge import save failed: {e}"),
946 None,
947 )),
948 }
949}
950
951#[cfg(test)]
952mod tests {
953 use std::path::{Path, PathBuf};
954 use std::sync::Arc;
955
956 use serde_json::{Map, Value, json};
957 use tokio::sync::RwLock;
958
959 use super::{handle_import, handle_pull};
960 use crate::server::tool_trait::ToolContext;
961 use crate::tools::ctx_knowledge::fact_served;
962 use lean_ctx::core::archive::delivered::LineageRefusal;
963 use lean_ctx::core::context_admission::stored::lineage_support::{
964 BOUND_FACT, TYPED_FACT, bound_fact, deliver, governed, project, reusable, scoped, two_facts,
965 };
966 use lean_ctx::core::handoff_ledger::{CreateLedgerInput, KnowledgeFactMini, create_ledger};
967 use lean_ctx::core::handoff_transfer_bundle::{
968 BundlePrivacyV1, build_bundle_v1, serialize_bundle_v1_pretty,
969 };
970 use lean_ctx::core::knowledge::ProjectKnowledge;
971 use lean_ctx::core::policy::runtime::TestPolicyOverride;
972 use lean_ctx::core::session::SessionState;
973
974 const POOL: &str = "pub fn lease() {}\n";
975 const AUTH: &str = "pub fn verify_rs256() {}\n";
976 const FINDING: &str = "the pool leaks one lease";
977 const TYPED_FINDING: &str = "noted without a file";
978 const DECISION: &str = "lease from the pool only";
979 const AWS_KEY: &str = concat!("AK", "IAIOSFODNN7EXAMPLE");
980 const MARKED: &str = "*** TOP SECRET ***\nlaunch window and codes";
981
982 fn context(root: &str, data: &Path) -> (ToolContext, Arc<RwLock<SessionState>>) {
986 let mut session = SessionState::new();
987 session.project_root = Some(root.to_owned());
988 let session = Arc::new(RwLock::new(session));
989 let canonical = data.canonicalize().unwrap_or_else(|_| data.to_path_buf());
990 let ctx = ToolContext {
991 project_root: root.to_owned(),
992 extra_roots: vec![
993 data.to_string_lossy().into_owned(),
994 canonical.to_string_lossy().into_owned(),
995 ],
996 session: Some(Arc::clone(&session)),
997 ..ToolContext::default()
998 };
999 (ctx, session)
1000 }
1001
1002 fn args(action: &str, path: &str) -> Map<String, Value> {
1003 json!({ "action": action, "path": path })
1004 .as_object()
1005 .cloned()
1006 .expect("object")
1007 }
1008
1009 fn ledger_of_a_writer(root: &str, typed: bool) -> PathBuf {
1013 std::fs::write(format!("{root}/pool.rs"), POOL).expect("source");
1014 let mut writer = SessionState::new();
1015 writer.project_root = Some(root.to_owned());
1016 writer.add_finding_for_file(Some("pool.rs"), Some(1), FINDING);
1017 writer.add_decision(DECISION, None);
1018 if typed {
1019 writer.add_finding_for_file(None, None, TYPED_FINDING);
1020 two_facts(root, "architecture");
1021 } else {
1022 bound_fact(root, "architecture");
1023 }
1024 let (_, path) = scoped(root, || {
1025 create_ledger(CreateLedgerInput {
1026 agent_id: None,
1027 client_name: None,
1028 project_root: Some(root.to_owned()),
1029 session: writer,
1030 tool_calls: Vec::new(),
1031 workflow: None,
1032 curated_refs: Vec::new(),
1033 request_id: None,
1034 })
1035 })
1036 .expect("create ledger");
1037 path
1038 }
1039
1040 fn pull(root: &str, ctx: &ToolContext, ledger: &Path) -> Result<String, String> {
1041 let args = args("pull", &ledger.to_string_lossy());
1042 scoped(root, || handle_pull(&args, ctx)).map_err(|error| error.message.to_string())
1043 }
1044
1045 fn finding(root: &str, session: &RwLock<SessionState>, summary: &str) -> (String, usize) {
1047 let session = session.blocking_read().clone();
1048 let found = session
1049 .findings
1050 .iter()
1051 .find(|finding| finding.summary == summary)
1052 .cloned();
1053 deliver(root, || match found {
1054 Some(found) if session.finding_served(&found) => format!("finding: {summary}"),
1055 Some(_) => "finding withheld".to_owned(),
1056 None => "no such finding".to_owned(),
1057 })
1058 }
1059
1060 fn recall(root: &str) -> (String, usize) {
1062 deliver(root, || {
1063 if fact_served(root, "architecture", "token-check", BOUND_FACT) {
1064 format!("[architecture] {BOUND_FACT}")
1065 } else {
1066 "fact withheld".to_owned()
1067 }
1068 })
1069 }
1070
1071 #[test]
1076 fn pull_binds_restored_items_to_the_joined_set_of_an_all_bound_ledger() {
1077 let data = governed();
1078 let _community = TestPolicyOverride::set(None);
1079 let (_directory, root) = project();
1080 let ledger = ledger_of_a_writer(&root, false);
1081 let (ctx, session) = context(&root, data.path());
1082
1083 let reply = pull(&root, &ctx, &ledger).expect("pull");
1084 assert!(reply.contains("imported_knowledge: 1"), "{reply}");
1085 assert!(reply.contains("skipped_withheld: 0"), "{reply}");
1086 assert!(
1087 !reply.contains(FINDING) && !reply.contains("RS256"),
1088 "{reply}"
1089 );
1090
1091 let (delivered, recorded) = finding(&root, &session, FINDING);
1092 assert_eq!(delivered, format!("finding: {FINDING}"));
1093 assert_eq!(recorded, 1, "the restored finding has a file lineage");
1094 assert_eq!(reusable(&root, &delivered), Ok(()));
1095 let (recalled, recorded) = recall(&root);
1096 assert!(recalled.contains("RS256"), "{recalled}");
1097 assert_eq!(recorded, 1);
1098
1099 std::fs::remove_file(format!("{root}/pool.rs")).expect("delete");
1101 assert_eq!(recall(&root), ("fact withheld".to_owned(), 0));
1102 assert_eq!(
1103 reusable(&root, &delivered),
1104 Err(LineageRefusal::SourceNoLongerAdmitted)
1105 );
1106 std::fs::write(format!("{root}/pool.rs"), POOL).expect("same bytes");
1107 assert_eq!(recall(&root).1, 1);
1108
1109 std::fs::remove_file(format!("{root}/auth.rs")).expect("delete");
1111 assert_eq!(
1112 finding(&root, &session, FINDING),
1113 ("finding withheld".to_owned(), 0)
1114 );
1115 }
1116
1117 #[test]
1121 fn pull_stores_nothing_from_a_withheld_ledger() {
1122 let data = governed();
1123 let _community = TestPolicyOverride::set(None);
1124 let (_directory, root) = project();
1125 let ledger = ledger_of_a_writer(&root, false);
1126 let (ctx, session) = context(&root, data.path());
1127 let untouched = || {
1128 let session = session.blocking_read();
1129 session.findings.is_empty() && session.decisions.is_empty() && session.task.is_none()
1130 };
1131 let facts = || {
1132 scoped(&root, || {
1133 ProjectKnowledge::load(&root).map_or(0, |knowledge| knowledge.facts.len())
1134 })
1135 };
1136 let before = facts();
1137
1138 std::fs::remove_file(format!("{root}/pool.rs")).expect("delete");
1139 let refusal = pull(&root, &ctx, &ledger).expect_err("the ledger is withheld");
1140 assert!(refusal.contains("store.source_unavailable"), "{refusal}");
1141 assert!(
1142 !refusal.contains(FINDING) && !refusal.contains("RS256"),
1143 "{refusal}"
1144 );
1145 assert!(untouched());
1146 assert_eq!(facts(), before);
1147
1148 std::fs::write(format!("{root}/pool.rs"), POOL).expect("same bytes");
1149 std::fs::write(
1150 format!("{root}/auth.rs"),
1151 format!("*** TOP SECRET ***\n{AUTH}"),
1152 )
1153 .expect("reclassify");
1154 let refusal = pull(&root, &ctx, &ledger).expect_err("the ledger is withheld");
1155 assert!(refusal.contains("store."), "{refusal}");
1156 assert!(
1157 !refusal.contains(FINDING) && !refusal.contains("RS256"),
1158 "{refusal}"
1159 );
1160 assert!(untouched());
1161 assert_eq!(facts(), before);
1162
1163 std::fs::write(format!("{root}/auth.rs"), AUTH).expect("same bytes");
1164 pull(&root, &ctx, &ledger).expect("served again");
1165 assert!(!untouched());
1166 }
1167
1168 fn recall_typed(root: &str) -> (String, usize) {
1170 deliver(root, || {
1171 if fact_served(root, "architecture", "release-duty", TYPED_FACT) {
1172 format!("[architecture] {TYPED_FACT}")
1173 } else {
1174 "fact withheld".to_owned()
1175 }
1176 })
1177 }
1178
1179 #[test]
1187 fn pull_from_a_mixed_ledger_keeps_the_binding_of_each_bound_part() {
1188 let data = governed();
1189 let _community = TestPolicyOverride::set(None);
1190 let (_directory, root) = project();
1191 let ledger = ledger_of_a_writer(&root, true);
1192 let (ctx, session) = context(&root, data.path());
1193
1194 let reply = pull(&root, &ctx, &ledger).expect("a mixed ledger is served");
1195 assert!(reply.contains("imported_knowledge: 2"), "{reply}");
1196 assert!(reply.contains("skipped_withheld: 0"), "{reply}");
1197
1198 let (bound, recorded) = finding(&root, &session, FINDING);
1199 assert_eq!(bound, format!("finding: {FINDING}"));
1200 assert_eq!(recorded, 1, "the bound finding keeps a file lineage");
1201 assert_eq!(reusable(&root, &bound), Ok(()));
1202 assert_eq!(recall(&root).1, 1, "the bound fact keeps a file lineage");
1203 let (typed, recorded) = finding(&root, &session, TYPED_FINDING);
1204 assert_eq!(typed, format!("finding: {TYPED_FINDING}"));
1205 assert_eq!(recorded, 0, "the unbound finding stays originless");
1206 assert_eq!(reusable(&root, &typed), Err(LineageRefusal::Unknown));
1207 let (typed_fact, recorded) = recall_typed(&root);
1208 assert!(typed_fact.contains("captain"), "{typed_fact}");
1209 assert_eq!(recorded, 0, "the unbound fact stays originless");
1210
1211 std::fs::remove_file(format!("{root}/auth.rs")).expect("delete");
1214 assert_eq!(
1215 finding(&root, &session, FINDING),
1216 ("finding withheld".to_owned(), 0)
1217 );
1218 assert_eq!(recall(&root), ("fact withheld".to_owned(), 0));
1219 assert_eq!(finding(&root, &session, TYPED_FINDING).0, typed);
1220 assert_eq!(recall_typed(&root).0, typed_fact);
1221
1222 let (fresh_ctx, fresh) = context(&root, data.path());
1224 let refusal = pull(&root, &fresh_ctx, &ledger).expect_err("the ledger is withheld");
1225 assert!(refusal.contains("store.source_unavailable"), "{refusal}");
1226 assert!(
1227 !refusal.contains(FINDING) && !refusal.contains(TYPED_FINDING),
1228 "{refusal}"
1229 );
1230 let fresh = fresh.blocking_read();
1231 assert!(fresh.findings.is_empty() && fresh.decisions.is_empty());
1232 }
1233
1234 #[test]
1238 fn pull_stores_nothing_from_a_ledger_that_disagrees_with_its_bound_record() {
1239 let data = governed();
1240 let _community = TestPolicyOverride::set(None);
1241 let (_directory, root) = project();
1242 let ledger = ledger_of_a_writer(&root, true);
1243 let (ctx, session) = context(&root, data.path());
1244 let refused = |what: &str| {
1245 let refusal = pull(&root, &ctx, &ledger).expect_err(what);
1246 assert!(
1247 refusal.contains("store.ledger_authority_inconsistent"),
1248 "{what}: {refusal}"
1249 );
1250 assert!(
1251 !refusal.contains(FINDING) && !refusal.contains("two leases"),
1252 "{refusal}"
1253 );
1254 let session = session.blocking_read();
1255 assert!(
1256 session.findings.is_empty() && session.decisions.is_empty(),
1257 "{what}"
1258 );
1259 };
1260
1261 let stored = std::fs::read_to_string(&ledger).expect("ledger");
1262 std::fs::write(
1263 &ledger,
1264 stored.replace(FINDING, "the pool leaks two leases"),
1265 )
1266 .expect("edit");
1267 refused("the text of a bound element was edited");
1268 std::fs::write(&ledger, &stored).expect("restore");
1269
1270 let sidecar = ledger.with_extension("authority");
1271 let mut authority: Value =
1272 serde_json::from_str(&std::fs::read_to_string(&sidecar).expect("sidecar"))
1273 .expect("json");
1274 authority
1275 .as_object_mut()
1276 .expect("object")
1277 .remove("bound_parts");
1278 std::fs::write(&sidecar, authority.to_string()).expect("strip");
1279 refused("the bound record was removed");
1280 }
1281
1282 #[test]
1286 fn pull_from_an_unchecked_ledger_restores_every_item_originless() {
1287 use lean_ctx::core::archive::bindings::{self, Item};
1288 let data = lean_ctx::core::data_dir::isolated_data_dir();
1289 let _community = TestPolicyOverride::set(None);
1290 let (_directory, root) = project();
1291 let ledger = ledger_of_a_writer(&root, true);
1292 let (ctx, session) = context(&root, data.path());
1293
1294 pull(&root, &ctx, &ledger).expect("pull");
1295 let session = session.blocking_read();
1296 assert_eq!(session.findings.len(), 2);
1297 for restored in &session.findings {
1298 let item = Item::SessionFinding {
1299 session: &session.id,
1300 file: None,
1301 };
1302 let entry = bindings::held(&item, &restored.summary, Some(&root));
1303 assert_eq!(entry.map(|held| held.is_some()), Ok(false));
1304 }
1305 }
1306
1307 #[test]
1311 fn import_stores_bundle_texts_originless_after_store_admission() {
1312 let data = governed();
1313 let _community = TestPolicyOverride::set(None);
1314 let (_directory, root) = project();
1315 let (ctx, session) = context(&root, data.path());
1316 let fact = |key: &str, value: &str| KnowledgeFactMini {
1317 category: "architecture".to_owned(),
1318 key: key.to_owned(),
1319 value: value.to_owned(),
1320 confidence: 0.9,
1321 };
1322 let mut bundle = build_bundle_v1(
1323 lean_ctx::core::handoff_ledger::HandoffLedgerV1::default(),
1324 Some(&root),
1325 BundlePrivacyV1::Redacted,
1326 );
1327 bundle.ledger.session.findings = vec![
1328 FINDING.to_owned(),
1329 format!("the deploy key is {AWS_KEY}"),
1330 MARKED.to_owned(),
1331 ];
1332 bundle.ledger.knowledge.facts = vec![
1333 fact("release-duty", TYPED_FACT),
1334 fact(
1335 "deploy-key",
1336 &format!("Deployments are signed with the key {AWS_KEY}"),
1337 ),
1338 fact("launch", MARKED),
1339 ];
1340 let json = serialize_bundle_v1_pretty(&bundle).expect("bundle");
1341 std::fs::write(format!("{root}/bundle.json"), json).expect("bundle file");
1342 let config = lean_ctx::core::paths::config_dir_read_only()
1345 .expect("isolated config dir")
1346 .join("config.toml");
1347 let governed_config = std::fs::read_to_string(&config).expect("governed config");
1348 std::fs::write(
1349 &config,
1350 format!("mcp_import_allow_unverified = true\n{governed_config}"),
1351 )
1352 .expect("opt in");
1353
1354 let reply = scoped(&root, || {
1355 handle_import(&args("import", "bundle.json"), &ctx)
1356 })
1357 .expect("import");
1358 assert!(reply.contains("imported_knowledge: 2"), "{reply}");
1359 assert!(reply.contains("skipped_withheld: 2"), "{reply}");
1360 for text in [FINDING, "captain", AWS_KEY, "launch window", "deploy key"] {
1361 assert!(!reply.contains(text), "counts only — {text}: {reply}");
1362 }
1363
1364 let summaries: Vec<String> = session
1365 .blocking_read()
1366 .findings
1367 .iter()
1368 .map(|finding| finding.summary.clone())
1369 .collect();
1370 assert_eq!(summaries.len(), 2, "{summaries:?}");
1371 assert_eq!(summaries[0], FINDING);
1372 assert!(
1373 summaries[1].starts_with("the deploy key is "),
1374 "{summaries:?}"
1375 );
1376 assert!(!summaries[1].contains(AWS_KEY), "stored masked");
1377
1378 let values: Vec<String> = scoped(&root, || {
1379 ProjectKnowledge::load(&root)
1380 .expect("store")
1381 .facts
1382 .iter()
1383 .map(|fact| fact.value.clone())
1384 .collect()
1385 });
1386 assert!(values.iter().any(|value| value == TYPED_FACT), "{values:?}");
1387 assert!(
1388 values
1389 .iter()
1390 .any(|value| value.starts_with("Deployments are signed"))
1391 );
1392 for value in &values {
1393 assert!(
1394 !value.contains(AWS_KEY) && !value.contains("launch"),
1395 "{values:?}"
1396 );
1397 }
1398
1399 let (delivered, recorded) = finding(&root, &session, FINDING);
1401 assert_eq!(delivered, format!("finding: {FINDING}"));
1402 assert_eq!(recorded, 0);
1403 let (typed, recorded) = deliver(&root, || {
1404 assert!(fact_served(
1405 &root,
1406 "architecture",
1407 "release-duty",
1408 TYPED_FACT
1409 ));
1410 format!("[architecture] {TYPED_FACT}")
1411 });
1412 assert_eq!(recorded, 0);
1413 assert_eq!(reusable(&root, &typed), Err(LineageRefusal::Unknown));
1414 }
1415
1416 use lean_ctx::core::handoff_transfer_bundle as bundle_mod;
1417
1418 fn import(root: &std::path::Path, file: &std::path::Path, extra: Value) -> String {
1419 let mut args = json!({
1420 "action": "import",
1421 "path": file.to_string_lossy(),
1422 "apply_workflow": false,
1423 "apply_session": false,
1424 "apply_knowledge": false,
1425 });
1426 if let (Some(args), Value::Object(extra)) = (args.as_object_mut(), extra) {
1427 args.extend(extra);
1428 }
1429 let ctx = ToolContext {
1430 project_root: root.to_string_lossy().to_string(),
1431 ..ToolContext::default()
1432 };
1433 match handle_import(args.as_object().unwrap(), &ctx) {
1434 Ok(text) => text,
1435 Err(e) => format!("error: {e:?}"),
1436 }
1437 }
1438
1439 #[test]
1442 fn mcp_import_requires_user_opt_in_for_unverified_bundles() {
1443 let data = lean_ctx::core::data_dir::isolated_data_dir();
1444 let project = tempfile::tempdir().unwrap();
1445
1446 let unsigned = bundle_mod::build_bundle_v1(
1447 lean_ctx::core::handoff_ledger::HandoffLedgerV1::default(),
1448 None,
1449 bundle_mod::BundlePrivacyV1::Redacted,
1450 );
1451 let key = ed25519_dalek::SigningKey::from_bytes(&[21u8; 32]);
1452 let mut foreign = unsigned.clone();
1453 foreign.signer_public_key = Some(lean_ctx::core::agent_identity::hex_encode(
1454 &key.verifying_key().to_bytes(),
1455 ));
1456 foreign.signer_agent_id = Some("trusted-release-bot".into());
1457 foreign.signature_scheme = Some(bundle_mod::BOUND_SIGNATURE_SCHEME.into());
1458 let canonical = serde_json::to_string(&foreign).unwrap();
1459 foreign.signature = Some(lean_ctx::core::agent_identity::hex_encode(
1460 &lean_ctx::core::agent_identity::sign_bytes_with(&key, canonical.as_bytes()),
1461 ));
1462
1463 let unsigned_path = project.path().join("unsigned.json");
1464 std::fs::write(&unsigned_path, serde_json::to_string(&unsigned).unwrap()).unwrap();
1465 let foreign_path = project.path().join("foreign.json");
1466 std::fs::write(&foreign_path, serde_json::to_string(&foreign).unwrap()).unwrap();
1467
1468 for path in [&unsigned_path, &foreign_path] {
1469 for extra in [json!({}), json!({"allow_unsigned": true})] {
1470 let out = import(project.path(), path, extra);
1471 assert!(out.starts_with("IMPORT BLOCKED"), "{out}");
1472 assert!(out.contains("mcp_import_allow_unverified"), "{out}");
1473 assert!(!out.contains("verified (signer="), "{out}");
1474 }
1475 }
1476
1477 let global = lean_ctx::core::config::Config::path().unwrap();
1478 std::fs::create_dir_all(global.parent().unwrap()).unwrap();
1479 std::fs::write(&global, "mcp_import_allow_unverified = true\n").unwrap();
1480 for path in [&unsigned_path, &foreign_path] {
1481 let out = import(project.path(), path, json!({}));
1482 assert!(!out.starts_with("IMPORT BLOCKED"), "{out}");
1483 }
1484 let out = import(project.path(), &foreign_path, json!({}));
1485 assert!(out.contains("(untrusted"), "{out}");
1486 std::fs::remove_file(&global).unwrap();
1487 drop(data);
1488 }
1489}