fix(tui): persist workspace trust in global config

fix(tui): persist workspace trust in global config
This commit is contained in:
Reid
2026-05-06 23:24:55 +08:00
committed by GitHub
parent 9a7cd9f937
commit 93cfb83a67
6 changed files with 240 additions and 37 deletions
+146
View File
@@ -1497,6 +1497,83 @@ fn home_config_path() -> Option<PathBuf> {
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::<toml::Value>(&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<PathBuf> {
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::<toml::Value>(&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<PathBuf> {
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();
+4
View File
@@ -189,6 +189,10 @@ fn load_context_file(path: &Path) -> Result<String, ProjectContextError> {
/// 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"),
+53 -10
View File
@@ -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");
+16 -14
View File
@@ -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<PathBuf> {
}
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<PathBuf> {
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<PathBuf> {
crate::config::save_workspace_trust(workspace)
}
@@ -16,20 +16,20 @@ pub fn lines(app: &App) -> Vec<Line<'static>> {
)));
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<Line<'static>> {
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
}
+10 -5
View File
@@ -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();