diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 03fa437b..114ff447 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -1497,6 +1497,83 @@ fn home_config_path() -> Option { effective_home_dir().map(|home| home.join(".deepseek").join("config.toml")) } +#[must_use] +pub(crate) fn is_workspace_trusted(workspace: &Path) -> bool { + let Some(config_path) = default_config_path() else { + return false; + }; + let Ok(raw) = fs::read_to_string(config_path) else { + return false; + }; + let Ok(doc) = toml::from_str::(&raw) else { + return false; + }; + workspace_trust_level_from_doc(&doc, workspace).is_some_and(is_trusted_level) +} + +pub(crate) fn save_workspace_trust(workspace: &Path) -> Result { + let config_path = default_config_path() + .context("Failed to resolve config path: home directory not found.")?; + ensure_parent_dir(&config_path)?; + + let mut doc = if config_path.exists() { + let raw = fs::read_to_string(&config_path)?; + toml::from_str::(&raw) + .with_context(|| format!("Failed to parse config at {}", config_path.display()))? + } else { + toml::Value::Table(toml::value::Table::new()) + }; + + let root = doc + .as_table_mut() + .context("Config root must be a TOML table.")?; + let projects = root + .entry("projects".to_string()) + .or_insert_with(|| toml::Value::Table(toml::value::Table::new())) + .as_table_mut() + .context("`projects` must be a table.")?; + let project = projects + .entry(workspace_config_key(workspace)) + .or_insert_with(|| toml::Value::Table(toml::value::Table::new())) + .as_table_mut() + .context("Project entry must be a table.")?; + project.insert( + "trust_level".to_string(), + toml::Value::String("trusted".to_string()), + ); + + let serialized = toml::to_string_pretty(&doc).context("failed to serialize updated config")?; + write_config_file_secure(&config_path, &serialized) + .with_context(|| format!("Failed to write config to {}", config_path.display()))?; + Ok(config_path) +} + +fn workspace_trust_level_from_doc<'a>(doc: &'a toml::Value, workspace: &Path) -> Option<&'a str> { + let workspace = canonicalize_or_keep(workspace); + let projects = doc.get("projects")?.as_table()?; + for (raw_path, project) in projects { + let project_path = canonicalize_or_keep(&expand_path(raw_path)); + if project_path == workspace { + return project.get("trust_level").and_then(toml::Value::as_str); + } + } + None +} + +fn is_trusted_level(level: &str) -> bool { + level.trim().eq_ignore_ascii_case("trusted") +} + +fn workspace_config_key(workspace: &Path) -> String { + canonicalize_or_keep(workspace) + .to_string_lossy() + .into_owned() +} + +fn canonicalize_or_keep(path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) +} + fn env_config_path() -> Option { if let Ok(path) = std::env::var("DEEPSEEK_CONFIG_PATH") { let trimmed = path.trim(); @@ -2976,6 +3053,75 @@ mod tests { Ok(()) } + #[test] + fn workspace_trust_round_trips_through_global_config() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "deepseek-tui-workspace-trust-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + let workspace = temp_root.join("project"); + fs::create_dir_all(&workspace)?; + + assert!(!is_workspace_trusted(&workspace)); + let saved = save_workspace_trust(&workspace)?; + + assert_eq!(saved, temp_root.join(".deepseek").join("config.toml")); + assert!(is_workspace_trusted(&workspace)); + assert!(!crate::tui::onboarding::needs_trust(&workspace)); + assert!( + !workspace.join(".deepseek").exists(), + "trust persistence must not create a project-local .deepseek directory" + ); + + let parsed: toml::Value = toml::from_str(&fs::read_to_string(saved)?)?; + assert_eq!( + workspace_trust_level_from_doc(&parsed, &workspace), + Some("trusted") + ); + Ok(()) + } + + #[test] + fn workspace_trust_reads_existing_projects_table() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "deepseek-tui-existing-project-trust-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + let workspace = temp_root.join("project"); + fs::create_dir_all(&workspace)?; + let config_path = temp_root.join(".deepseek").join("config.toml"); + fs::create_dir_all(config_path.parent().unwrap())?; + fs::write( + &config_path, + format!( + "[projects.\"{}\"]\ntrust_level = \"trusted\"\n", + workspace_config_key(&workspace) + .replace('\\', "\\\\") + .replace('"', "\\\"") + ), + )?; + + assert!(is_workspace_trusted(&workspace)); + assert!(!crate::tui::onboarding::needs_trust(&workspace)); + Ok(()) + } + #[test] fn save_api_key_rejects_empty_input() { let _lock = lock_test_env(); diff --git a/crates/tui/src/project_context.rs b/crates/tui/src/project_context.rs index 87440fb3..107db6ed 100644 --- a/crates/tui/src/project_context.rs +++ b/crates/tui/src/project_context.rs @@ -189,6 +189,10 @@ fn load_context_file(path: &Path) -> Result { /// Check if this project is marked as trusted fn check_trust_status(workspace: &Path) -> bool { + if crate::config::is_workspace_trusted(workspace) { + return true; + } + // Check for trust markers let trust_markers = [ workspace.join(".deepseek").join("trusted"), diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 0af5b72e..79ccfceb 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -56,6 +56,34 @@ pub enum OnboardingState { None, } +fn initial_onboarding_state( + skip_onboarding: bool, + was_onboarded: bool, + needs_api_key: bool, + needs_workspace_trust: bool, +) -> OnboardingState { + if skip_onboarding || (was_onboarded && !needs_api_key && !needs_workspace_trust) { + return OnboardingState::None; + } + + if was_onboarded && needs_api_key { + OnboardingState::ApiKey + } else if was_onboarded && needs_workspace_trust { + OnboardingState::TrustDirectory + } else { + OnboardingState::Welcome + } +} + +fn onboarding_is_workspace_trust_gate( + skip_onboarding: bool, + was_onboarded: bool, + needs_api_key: bool, + needs_workspace_trust: bool, +) -> bool { + !skip_onboarding && was_onboarded && !needs_api_key && needs_workspace_trust +} + /// Supported application modes for the TUI. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AppMode { @@ -735,6 +763,7 @@ pub struct App { // Onboarding pub onboarding: OnboardingState, pub onboarding_needs_api_key: bool, + pub onboarding_workspace_trust_gate: bool, pub api_key_env_only: bool, pub api_key_input: String, pub api_key_cursor: usize, @@ -1096,7 +1125,6 @@ impl App { let needs_api_key = !has_api_key(config); let api_key_env_only = crate::config::active_provider_uses_env_only_api_key(config); let was_onboarded = crate::tui::onboarding::is_onboarded(); - let needs_onboarding = !skip_onboarding && (!was_onboarded || needs_api_key); let settings = Settings::load().unwrap_or_else(|_| Settings::default()); let auto_compact = settings.auto_compact; let calm_mode = settings.calm_mode; @@ -1147,6 +1175,20 @@ impl App { } else { preferred_mode }; + let needs_workspace_trust = + initial_mode != AppMode::Yolo && crate::tui::onboarding::needs_trust(&workspace); + let onboarding = initial_onboarding_state( + skip_onboarding, + was_onboarded, + needs_api_key, + needs_workspace_trust, + ); + let onboarding_workspace_trust_gate = onboarding_is_workspace_trust_gate( + skip_onboarding, + was_onboarded, + needs_api_key, + needs_workspace_trust, + ); let yolo_restore = if initial_mode == AppMode::Yolo { Some(YoloRestoreState { @@ -1280,16 +1322,9 @@ impl App { pending_subagent_dispatch: None, agent_activity_started_at: None, ui_theme, - onboarding: if needs_onboarding { - if was_onboarded && needs_api_key { - OnboardingState::ApiKey - } else { - OnboardingState::Welcome - } - } else { - OnboardingState::None - }, + onboarding, onboarding_needs_api_key: needs_api_key, + onboarding_workspace_trust_gate, api_key_env_only, api_key_input: String::new(), api_key_cursor: 0, @@ -3832,6 +3867,14 @@ mod tests { assert!(app.trust_mode); } + #[test] + fn onboarded_user_still_gets_workspace_trust_prompt_when_needed() { + assert_eq!( + initial_onboarding_state(false, true, false, true), + OnboardingState::TrustDirectory + ); + } + #[test] fn new_caches_workspace_skills_for_slash_menu() { let tmp = tempfile::TempDir::new().expect("tempdir"); diff --git a/crates/tui/src/tui/onboarding/mod.rs b/crates/tui/src/tui/onboarding/mod.rs index 47fa694b..b612c47d 100644 --- a/crates/tui/src/tui/onboarding/mod.rs +++ b/crates/tui/src/tui/onboarding/mod.rs @@ -41,24 +41,26 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { }; if !lines.is_empty() { - let (step, total) = onboarding_step(app); - let panel = Block::default() + let mut panel = Block::default() .title(Line::from(Span::styled( " DeepSeek TUI ", Style::default() .fg(palette::DEEPSEEK_BLUE) .add_modifier(Modifier::BOLD), ))) - .title_bottom(Line::from(Span::styled( - format!(" Step {step}/{total} "), - Style::default() - .fg(palette::TEXT_MUTED) - .add_modifier(Modifier::BOLD), - ))) .borders(Borders::ALL) .border_style(Style::default().fg(palette::BORDER_COLOR)) .style(Style::default().bg(palette::DEEPSEEK_SLATE)) .padding(Padding::new(2, 2, 1, 1)); + if !app.onboarding_workspace_trust_gate { + let (step, total) = onboarding_step(app); + panel = panel.title_bottom(Line::from(Span::styled( + format!(" Step {step}/{total} "), + Style::default() + .fg(palette::TEXT_MUTED) + .add_modifier(Modifier::BOLD), + ))); + } let inner = panel.inner(content_area); f.render_widget(panel, content_area); let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false }); @@ -152,6 +154,10 @@ pub fn mark_onboarded() -> std::io::Result { } pub fn needs_trust(workspace: &Path) -> bool { + if crate::config::is_workspace_trusted(workspace) { + return false; + } + let markers = [ workspace.join(".deepseek").join("trusted"), workspace.join(".deepseek").join("trust.json"), @@ -159,10 +165,6 @@ pub fn needs_trust(workspace: &Path) -> bool { !markers.iter().any(|path| path.exists()) } -pub fn mark_trusted(workspace: &Path) -> std::io::Result { - let dir = workspace.join(".deepseek"); - std::fs::create_dir_all(&dir)?; - let path = dir.join("trusted"); - std::fs::write(&path, "")?; - Ok(path) +pub fn mark_trusted(workspace: &Path) -> anyhow::Result { + crate::config::save_workspace_trust(workspace) } diff --git a/crates/tui/src/tui/onboarding/trust_directory.rs b/crates/tui/src/tui/onboarding/trust_directory.rs index 44c18b7d..cdbbed54 100644 --- a/crates/tui/src/tui/onboarding/trust_directory.rs +++ b/crates/tui/src/tui/onboarding/trust_directory.rs @@ -16,20 +16,20 @@ pub fn lines(app: &App) -> Vec> { ))); lines.push(Line::from("")); lines.push(Line::from(Span::styled( - "Allow DeepSeek to access files outside this workspace?", + "Do you trust the contents of this directory?", Style::default().fg(palette::TEXT_PRIMARY), ))); lines.push(Line::from(Span::styled( - format!("Workspace: {}", crate::utils::display_path(&app.workspace)), + format!("You are in {}", crate::utils::display_path(&app.workspace)), Style::default().fg(palette::TEXT_MUTED), ))); lines.push(Line::from("")); lines.push(Line::from(Span::styled( - "Y = let reviews, searches, and agents reach outside this workspace when a task needs it.", + "Working with untrusted contents comes with higher risk of prompt injection.", Style::default().fg(palette::TEXT_MUTED), ))); lines.push(Line::from(Span::styled( - "N = keep file access scoped to this workspace and review approvals case by case.", + "Trusting this directory records it in global config and enables trusted workspace mode.", Style::default().fg(palette::TEXT_MUTED), ))); if let Some(message) = app.status_message.as_deref() { @@ -43,19 +43,22 @@ pub fn lines(app: &App) -> Vec> { lines.push(Line::from(vec![ Span::styled("Press ", Style::default().fg(palette::TEXT_MUTED)), Span::styled( - "Y", + "1/Y", Style::default() .fg(palette::TEXT_PRIMARY) .add_modifier(Modifier::BOLD), ), - Span::styled(" to trust, ", Style::default().fg(palette::TEXT_MUTED)), Span::styled( - "N", + " to trust and continue, ", + Style::default().fg(palette::TEXT_MUTED), + ), + Span::styled( + "2/N", Style::default() .fg(palette::TEXT_PRIMARY) .add_modifier(Modifier::BOLD), ), - Span::styled(" to skip", Style::default().fg(palette::TEXT_MUTED)), + Span::styled(" to quit", Style::default().fg(palette::TEXT_MUTED)), ])); lines } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index dfa12094..b6789806 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -1720,14 +1720,19 @@ async fn run_event_loop( } OnboardingState::None => {} }, - KeyCode::Char('y') | KeyCode::Char('Y') + KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Char('1') if app.onboarding == OnboardingState::TrustDirectory => { match onboarding::mark_trusted(&app.workspace) { Ok(_) => { app.trust_mode = true; app.status_message = None; - app.onboarding = OnboardingState::Tips; + if app.onboarding_workspace_trust_gate { + app.onboarding_workspace_trust_gate = false; + app.onboarding = OnboardingState::None; + } else { + app.onboarding = OnboardingState::Tips; + } } Err(err) => { app.status_message = @@ -1735,11 +1740,11 @@ async fn run_event_loop( } } } - KeyCode::Char('n') | KeyCode::Char('N') + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Char('2') if app.onboarding == OnboardingState::TrustDirectory => { - app.status_message = None; - app.onboarding = OnboardingState::Tips; + let _ = engine_handle.send(Op::Shutdown).await; + return Ok(()); } KeyCode::Backspace if app.onboarding == OnboardingState::ApiKey => { app.delete_api_key_char();