Skip to main content

lean_ctx_server/tools/registered/
ctx_handoff.rs

1// SPDX-License-Identifier: Apache-2.0
2use 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    /// Declared for `show` only: its reply is the ledger path and the ledger
101    /// as `handoff_ledger::load_ledger` serves it, which publishes the joined
102    /// files of a ledger made of curated excerpts and of findings and facts
103    /// that were all bound at its creation, and marks the capture incomplete
104    /// for every other ledger (an originless finding or fact, session text
105    /// or a workflow has no source to re-check).
106    ///
107    /// Not declared: `create` (curated refs come from the read cache),
108    /// `merge` (the reply carries digests and counts, never ledger text),
109    /// `export`/`import` (bundle files, read outside the admitted readers),
110    /// `pull` (applies the ledger; the reply carries counts), `list`, `clear`.
111    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
140/// `merge`: the served ledgers at `ledgers` become one new stored ledger
141/// (`handoff_ledger::merge_ledgers`). A withheld input refuses the merge.
142fn 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        // A stored ledger (e.g. a merged one), served as a whole first.
281        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
295/// A new ledger of the current session, as `create` writes it.
296fn 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    // Sign every export (GL #465) so receivers can verify origin + integrity.
364    // Signing failure (e.g. unwritable key dir) degrades to an unsigned bundle
365    // with a warning — export stays local-first, import-side policy decides.
366    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        // Read-only-roots choke point (#475): export must not write a bundle into
424        // a read-only root even when the jail allows reads.
425        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        // The structured formats (json/a2a) stay machine-parseable: signature
439        // state is visible in the payload itself. Only the human summary
440        // carries the explicit signing line.
441        "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 envelope (GL#449): spec-shaped Task object a foreign agent can
460        // parse without knowing the lean-ctx bundle format.
461        "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    // The ledger is re-authorized as a whole before anything is applied: a
486    // withheld ledger returns here, and nothing of it is stored.
487    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/// Where the texts a write path is about to store come from.
543#[derive(Clone, Copy)]
544enum Incoming<'a> {
545    /// `pull`: a ledger of this store that was just served as a whole. With a
546    /// lineage, each restored finding and fact that was bound at the
547    /// ledger's creation is bound to the ledger's joined source set, and
548    /// each that was not is stored originless; without a lineage all are
549    /// stored originless, as before.
550    Ledger(Option<&'a LedgerLineage>),
551    /// `import`: the ledger of a transfer bundle. A bundle carries no binding
552    /// and may come from a machine whose files cannot be read here: its texts
553    /// are caller-supplied text without source authority. Each passes store
554    /// admission first and is stored originless.
555    Bundle,
556}
557
558impl<'a> Incoming<'a> {
559    /// `text` as it may be stored, `None` when it must not be: a bundle text
560    /// that admission withholds or that is restricted is never stored (E3),
561    /// one it masks is stored masked.
562    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
577/// Apply the session part of a ledger to `session`. Returns how many texts
578/// were NOT stored (a count only; no text of them is reported).
579fn 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        // The index is the finding's place in the stored ledger's list.
602        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
620/// Record `summary`, a finding restored from a ledger, in `session`. Returns
621/// whether the session keeps it.
622///
623/// Without a lineage, and when the finding is not in the ledger's bound-part
624/// set, it is recorded originless, as before — an unbound finding gets no
625/// binding from bound siblings. A finding that was bound at the ledger's
626/// creation is bound to the ledger's joined source set
627/// (`LedgerLineage::carry`) — to all files of the ledger, not only its own.
628/// While protection is active such a finding whose binding cannot be made or
629/// written is not kept: it would be an unbound copy of text whose sources
630/// are bound.
631fn 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
666/// How the user admits a bundle that the import refused (GHSA-gvg8-285q-m62g).
667const 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
671/// Global-only user opt-in; never merged from project config.
672fn 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    // Signature enforcement (GL #465): a bundle with broken/tampered signature
706    // material is rejected fail-closed; legacy unsigned bundles import with a
707    // warning; valid signatures surface the verified signer.
708    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            // GHSA-gvg8-285q-m62g: imported content is replayed into session and
730            // knowledge stores. Unsigned or untrusted-signer bundles need the
731            // user's global opt-in — a tool argument would let a prompt-injected
732            // model vouch for the file itself.
733            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    // A bundle is caller-supplied text: no binding travels in it, and its
810    // ledger never passed `load_ledger`. Every text is admitted for the store
811    // before it is kept and is stored originless; the reply carries counts.
812    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
843/// Shared knowledge import logic used by both pull and import actions.
844/// Returns (facts stored, contradictions, facts not stored).
845///
846/// A fact value is stored as `incoming` allows ([`Incoming::storable`]). From
847/// a ledger with a lineage the fact is bound to the ledger's joined source
848/// set once the store kept exactly its text
849/// (`consolidation_engine::store_carried`); while protection is active a
850/// fact that cannot be bound that way is not stored. Otherwise it is stored
851/// originless.
852fn 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    // Import under the cross-process lock (#326/#594): the daemon, the MCP
883    // server and a CLI handoff can all write knowledge concurrently.
884    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    /// A tool context of `root` with a fresh session, as `pull` and `import`
983    /// need it. Ledgers live in the data directory, which is made a trusted
984    /// root of the context so that a ledger path resolves.
985    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    /// The ledger of a writer session of `root`: a finding bound to
1010    /// `pool.rs`, one decision, the fact bound to `auth.rs` and — with
1011    /// `typed`, a mixed ledger — a finding and a fact without a binding.
1012    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    /// Deliver the finding `summary` of `session` the way a renderer does.
1046    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    /// Deliver the fact `token-check` the way a recall does.
1061    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    /// A finding and a fact pulled from a ledger whose parts were all bound
1072    /// are bound to the ledger's joined source set: their delivery is
1073    /// registered, and each is withheld when ANY file of the set goes — also
1074    /// the file the other item came from (the stated over-approximation).
1075    #[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        // `pool.rs` is the finding's file: the fact is bound to it as well.
1100        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        // `auth.rs` is the fact's file: the finding is bound to it as well.
1110        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    /// Nothing is stored from a withheld ledger: `pull` answers with the
1118    /// typed refusal before any write, for a deleted finding source and for
1119    /// a reclassified fact source; with the sources back it applies.
1120    #[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    /// Deliver the fact `release-duty`, which no writer ever bound.
1169    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    /// A mixed ledger — one bound and one unbound finding, one bound and one
1180    /// unbound fact — does not launder: `pull` restores the bound items WITH
1181    /// a binding to the ledger's joined set (registered; withheld when a file
1182    /// of the set goes) and the unbound items originless (never registered,
1183    /// never withheld for a file: they got no binding from their siblings).
1184    /// Once a bound source is withdrawn the whole ledger is withheld and
1185    /// nothing is restored.
1186    #[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        // A file of the joined set goes: the bound items are withheld, the
1212        // unbound ones are delivered as before.
1213        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        // The same ledger now: withheld as a whole, nothing restored.
1223        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    /// A ledger created under protection whose bound record is gone, or
1235    /// whose bound element was edited, is not treated as "nothing was
1236    /// bound": `pull` gets the typed refusal and stores nothing.
1237    #[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    /// A ledger written without protection carries the unchecked marker and
1283    /// no bound record: every item restored from it is originless — no
1284    /// register entry is written for a restored finding.
1285    #[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    /// A bundle is caller-supplied text: a clean value is stored originless,
1308    /// a key-shaped value is stored masked, a restricted value is skipped
1309    /// and counted, and the reply states counts only.
1310    #[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        // The bundle is unsigned: over MCP that needs the user's global opt-in
1343        // (GHSA-gvg8-285q-m62g), a top-level key ahead of the governed table.
1344        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        // Stored originless: the deliveries have no lineage.
1400        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    /// GHSA-gvg8-285q-m62g: over MCP, unsigned and untrusted-signer bundles
1440    /// need the user's global opt-in; the model cannot grant it by argument.
1441    #[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}