fix(tui): persist workspace trust in global config
fix(tui): persist workspace trust in global config
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user