From f779c7de6e146662782fd6814936128d9e6c4ec6 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 5 May 2026 02:07:58 -0500 Subject: [PATCH] fix(security): refuse to auto-recover checkpoint from another workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported by @Hmbown: launching `deepseek` from any directory silently auto-recovered the most recent interrupted session, even if that session originated in a completely different workspace. Tools then operated on file paths from the prior workspace while the status bar showed the *current* workspace name — a confusing trust-boundary violation that also leaks api_messages, working_set entries, and any secrets the prior session had accumulated into a new terminal that was never meant to see them. `try_recover_checkpoint()` now compares the saved session's workspace to `std::env::current_dir()` (canonicalised, with a strict-equality fallback when canonicalisation fails — e.g. the original workspace was deleted) and only auto-recovers on a match. On a mismatch the checkpoint is still persisted as a regular session and cleared, so the user can recover it explicitly via `deepseek resume ` after `cd`-ing back to the original workspace — no data is lost. A one-line stderr notice points at the resume command. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/tui/src/main.rs | 53 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 445d8110..ffc2dfc4 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -3367,12 +3367,21 @@ fn is_zellij() -> bool { } /// Check for a crash-recovery checkpoint and return the session ID if -/// recovery is possible. +/// recovery is possible *and* the checkpoint belongs to the current +/// workspace. /// /// The checkpoint must exist and its file mtime must be within 24 hours. -/// On success the checkpoint is persisted as a regular session, cleared, -/// and a notice is printed to stderr. Returns `None` if there is nothing -/// to recover. +/// **The checkpoint's workspace must also match `std::env::current_dir()` +/// after canonicalisation.** If the workspace doesn't match, the +/// checkpoint is persisted as a regular session (so the user can find it +/// via `deepseek sessions` / `deepseek resume `) and cleared, and the +/// new launch starts fresh — silently importing a session from another +/// project would leak api_messages, working_set entries, and possibly +/// secrets across directories (see v0.8.12 cross-workspace bleed report). +/// +/// On a successful match the checkpoint is persisted as a regular session, +/// cleared, and a notice is printed to stderr. Returns `None` if there is +/// nothing to recover or the workspace doesn't match. fn try_recover_checkpoint() -> Option { let manager = session_manager::SessionManager::default_location().ok()?; let session = manager.load_checkpoint().ok().flatten()?; @@ -3393,6 +3402,42 @@ fn try_recover_checkpoint() -> Option { return None; } + // Refuse to silently restore a session from another workspace. We compare + // canonicalised paths so that `~/foo` vs `/Users/x/foo` and symlink + // variants resolve consistently. If either side fails to canonicalise + // (e.g. the saved workspace was deleted), fall back to a strict equality + // check on the raw paths. + let session_workspace = session.metadata.workspace.clone(); + let current_workspace = std::env::current_dir().ok()?; + let workspace_matches = { + let lhs = std::fs::canonicalize(&session_workspace).ok(); + let rhs = std::fs::canonicalize(¤t_workspace).ok(); + match (lhs, rhs) { + (Some(a), Some(b)) => a == b, + _ => session_workspace == current_workspace, + } + }; + + if !workspace_matches { + // Persist the checkpoint so the user can find it via `deepseek + // sessions`, then clear it so the next launch in this folder doesn't + // re-trip the nag. Print a one-line notice pointing at the explicit + // resume command — but DO NOT auto-load the session here. + let session_id_for_notice = session.metadata.id.clone(); + let _ = manager.save_session(&session); + let _ = manager.clear_checkpoint(); + eprintln!( + "Note: an interrupted session ({}…) from another workspace ({}) is \ + available. Run `deepseek resume {}` from there to recover it, or \ + use `deepseek sessions` to list all saved sessions. Starting fresh \ + here.", + &session_id_for_notice.chars().take(8).collect::(), + session_workspace.display(), + session_id_for_notice, + ); + return None; + } + let session_id = session.metadata.id.clone(); // Persist the checkpoint as a regular session so the TUI can load it by id.