fix(security): refuse to auto-recover checkpoint from another workspace

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 <ID>` 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) <noreply@anthropic.com>
This commit is contained in:
Hunter Bown
2026-05-05 02:07:58 -05:00
parent 546ef939bd
commit a6d9d7cf5b
+49 -4
View File
@@ -3369,12 +3369,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 <id>`) 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<String> {
let manager = session_manager::SessionManager::default_location().ok()?;
let session = manager.load_checkpoint().ok().flatten()?;
@@ -3395,6 +3404,42 @@ fn try_recover_checkpoint() -> Option<String> {
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(&current_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::<String>(),
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.