feat(privacy): contract \$HOME to ~ in user-visible display paths

Anywhere the TUI, doctor stdout, setup stdout, or onboarding shows a
file path, it used to print the absolute form (e.g. /Users/<name>/...).
On macOS/Linux the home-directory segment reveals the OS account name,
which is often the same as a public handle — undesirable for users who
share screenshots, screencasts, or paste doctor output into a public
help request.

Adds `crate::utils::display_path` that contracts a leading $HOME to `~`
and falls through unchanged otherwise. Used at every viewer-visible site:

  doctor:    workspace, config.toml, MCP config, all skills dirs,
             selected skills dir, tools dir, plugins dir
  setup:     workspace, skills/tools/plugins paths and status output
  TUI:       context inspector header, trust-directory onboarding,
             shell-job cwd (sidebar + detail pager), subagent task header

Persisted state, audit log, session checkpoints, and LLM-bound system
prompts intentionally keep the absolute path — those need full fidelity
to resolve correctly across processes and the LLM provider sees
absolute paths anyway by virtue of the workspace summary.

`display_path` has 4 tests covering: home contraction, bare-`~` for
home itself, untouched-when-unrelated, and a username-prefix regression
guard (so `/Users/alice2/...` doesn't get rewritten when $HOME is
`/Users/alice`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hunter Bown
2026-05-01 02:46:20 -05:00
parent d8acd6e3cb
commit 1512afae69
6 changed files with 140 additions and 29 deletions
+30 -24
View File
@@ -979,7 +979,7 @@ fn run_setup(config: &Config, workspace: &Path, args: SetupArgs) -> Result<()> {
"DeepSeek Setup".truecolor(aqua_r, aqua_g, aqua_b).bold()
);
println!("{}", "==============".truecolor(sky_r, sky_g, sky_b));
println!("Workspace: {}", workspace.display());
println!("Workspace: {}", crate::utils::display_path(workspace));
if run_mcp {
let mcp_path = config.mcp_config_path();
@@ -1022,10 +1022,13 @@ fn run_setup(config: &Config, workspace: &Path, args: SetupArgs) -> Result<()> {
if args.local {
println!(
" Local skills dir enabled for this workspace: {}",
skills_dir.display()
crate::utils::display_path(&skills_dir)
);
} else {
println!(" Skills dir: {}", skills_dir.display());
println!(
" Skills dir: {}",
crate::utils::display_path(&skills_dir)
);
}
println!(" Next: run the TUI and use `/skills` then `/skill getting-started`.");
}
@@ -1035,7 +1038,7 @@ fn run_setup(config: &Config, workspace: &Path, args: SetupArgs) -> Result<()> {
let (dir, readme_status, example_status) = init_tools_dir(&tools_dir, args.force)?;
report_write_status("Tools README", &dir.join("README.md"), readme_status);
report_write_status("Example tool", &dir.join("example.sh"), example_status);
println!(" Tools dir: {}", dir.display());
println!(" Tools dir: {}", crate::utils::display_path(&dir));
println!(" Next: drop scripts here; surface them via skills/MCP when ready.");
}
@@ -1045,7 +1048,10 @@ fn run_setup(config: &Config, workspace: &Path, args: SetupArgs) -> Result<()> {
init_plugins_dir(&plugins_dir, args.force)?;
report_write_status("Plugins README", &readme_path, readme_status);
report_write_status("Example plugin", &example_path, example_status);
println!(" Plugins dir: {}", plugins_dir.display());
println!(
" Plugins dir: {}",
crate::utils::display_path(&plugins_dir)
);
println!(" Next: copy the example dir, edit PLUGIN.md, wire via skill/MCP.");
}
@@ -1200,7 +1206,7 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> {
println!(
" · skills: {} at {}",
skills_count_for(&skills_dir),
skills_dir.display()
crate::utils::display_path(&skills_dir)
);
let tools_dir = default_tools_dir();
@@ -1216,7 +1222,7 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> {
} else {
0
},
tools_dir.display()
crate::utils::display_path(&tools_dir)
);
let plugins_dir = default_plugins_dir();
@@ -1232,7 +1238,7 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> {
} else {
0
},
plugins_dir.display()
crate::utils::display_path(&plugins_dir)
);
let sandbox = crate::sandbox::get_platform_sandbox();
@@ -1348,16 +1354,16 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
println!(
" {} config.toml found at {}",
"".truecolor(aqua_r, aqua_g, aqua_b),
config_path.display()
crate::utils::display_path(&config_path)
);
} else {
println!(
" {} config.toml not found at {} (using defaults/env)",
"!".truecolor(sky_r, sky_g, sky_b),
config_path.display()
crate::utils::display_path(&config_path)
);
}
println!(" workspace: {}", workspace.display());
println!(" workspace: {}", crate::utils::display_path(workspace));
// Check API keys
println!();
@@ -1477,7 +1483,7 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
println!(
" {} MCP config found at {}",
"".truecolor(aqua_r, aqua_g, aqua_b),
mcp_config_path.display()
crate::utils::display_path(&mcp_config_path)
);
match load_mcp_config(&mcp_config_path) {
Ok(cfg) if cfg.servers.is_empty() => {
@@ -1532,7 +1538,7 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
println!(
" {} MCP config not found at {}",
"·".dimmed(),
mcp_config_path.display()
crate::utils::display_path(&mcp_config_path)
);
println!(" Run `deepseek mcp init` or `deepseek setup --mcp`.");
}
@@ -1561,14 +1567,14 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
println!(
" {} local skills dir found at {} ({} items)",
"".truecolor(aqua_r, aqua_g, aqua_b),
local_skills_dir.display(),
crate::utils::display_path(&local_skills_dir),
describe_dir(&local_skills_dir)
);
} else {
println!(
" {} local skills dir not found at {}",
"·".dimmed(),
local_skills_dir.display()
crate::utils::display_path(&local_skills_dir)
);
}
@@ -1576,14 +1582,14 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
println!(
" {} .agents skills dir found at {} ({} items)",
"".truecolor(aqua_r, aqua_g, aqua_b),
agents_skills_dir.display(),
crate::utils::display_path(&agents_skills_dir),
describe_dir(&agents_skills_dir)
);
} else {
println!(
" {} .agents skills dir not found at {}",
"·".dimmed(),
agents_skills_dir.display()
crate::utils::display_path(&agents_skills_dir)
);
}
@@ -1591,21 +1597,21 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
println!(
" {} global skills dir found at {} ({} items)",
"".truecolor(aqua_r, aqua_g, aqua_b),
global_skills_dir.display(),
crate::utils::display_path(&global_skills_dir),
describe_dir(&global_skills_dir)
);
} else {
println!(
" {} global skills dir not found at {}",
"·".dimmed(),
global_skills_dir.display()
crate::utils::display_path(&global_skills_dir)
);
}
println!(
" {} selected skills dir: {}",
"·".dimmed(),
selected_skills_dir.display()
crate::utils::display_path(&selected_skills_dir)
);
if !agents_skills_dir.exists() && !local_skills_dir.exists() && !global_skills_dir.exists() {
println!(" Run `deepseek setup --skills` (or add --local for ./skills).");
@@ -1620,14 +1626,14 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
println!(
" {} tools dir found at {} ({} items)",
"".truecolor(aqua_r, aqua_g, aqua_b),
tools_dir.display(),
crate::utils::display_path(&tools_dir),
count
);
} else {
println!(
" {} tools dir not found at {}",
"·".dimmed(),
tools_dir.display()
crate::utils::display_path(&tools_dir)
);
println!(" Run `deepseek-tui setup --tools` to scaffold a starter dir.");
}
@@ -1641,14 +1647,14 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
println!(
" {} plugins dir found at {} ({} items)",
"".truecolor(aqua_r, aqua_g, aqua_b),
plugins_dir.display(),
crate::utils::display_path(&plugins_dir),
count
);
} else {
println!(
" {} plugins dir not found at {}",
"·".dimmed(),
plugins_dir.display()
crate::utils::display_path(&plugins_dir)
);
println!(" Run `deepseek-tui setup --plugins` to scaffold a starter dir.");
}
+5 -1
View File
@@ -30,7 +30,11 @@ pub fn build_context_inspector_text(app: &App) -> String {
let _ = writeln!(out, "Session Context");
let _ = writeln!(out, "---------------");
let _ = writeln!(out, "Model: {}", app.model);
let _ = writeln!(out, "Workspace: {}", app.workspace.display());
let _ = writeln!(
out,
"Workspace: {}",
crate::utils::display_path(&app.workspace)
);
if let Some(session_id) = app.current_session_id.as_deref() {
let _ = writeln!(out, "Session: {}", session_id);
}
@@ -20,7 +20,7 @@ pub fn lines(app: &App) -> Vec<Line<'static>> {
Style::default().fg(palette::TEXT_PRIMARY),
)));
lines.push(Line::from(Span::styled(
format!("Workspace: {}", app.workspace.display()),
format!("Workspace: {}", crate::utils::display_path(&app.workspace)),
Style::default().fg(palette::TEXT_MUTED),
)));
lines.push(Line::from(""));
+2 -2
View File
@@ -52,7 +52,7 @@ pub(super) fn format_shell_job_list(jobs: &[ShellJobSnapshot]) -> String {
job.exit_code,
task
));
lines.push(format!(" cwd: {}", job.cwd.display()));
lines.push(format!(" cwd: {}", crate::utils::display_path(&job.cwd)));
lines.push(format!(" cmd: {}", job.command));
let tail = if !job.stderr_tail.trim().is_empty() {
job.stderr_tail.trim()
@@ -115,7 +115,7 @@ fn format_shell_job_detail(detail: &ShellJobDetail) -> String {
format!("Job: {}", job.id),
format!("Status: {}", status_label(&job.status, job.stale)),
format!("Command: {}", job.command),
format!("Cwd: {}", job.cwd.display()),
format!("Cwd: {}", crate::utils::display_path(&job.cwd)),
format!("Elapsed: {}", format_elapsed(job.elapsed_ms)),
format!("Exit Code: {:?}", job.exit_code),
format!("Stdin Available: {}", job.stdin_available),
+4 -1
View File
@@ -489,7 +489,10 @@ fn format_task_detail(task: &TaskRecord) -> String {
lines.push(format!("Status: {}", task_status_label(task.status)));
lines.push(format!("Mode: {}", task.mode));
lines.push(format!("Model: {}", task.model));
lines.push(format!("Workspace: {}", task.workspace.display()));
lines.push(format!(
"Workspace: {}",
crate::utils::display_path(&task.workspace)
));
if let Some(thread_id) = task.thread_id.as_ref() {
lines.push(format!("Runtime Thread: {thread_id}"));
}
+98
View File
@@ -186,6 +186,33 @@ pub fn url_encode(input: &str) -> String {
encoded
}
/// Render a path for **user-facing display** with the home directory
/// contracted to `~`. Use this in the TUI, doctor/setup stdout, and any
/// other place a viewer might see the output (screenshot, video,
/// pasted-into-issue help). On macOS/Linux the absolute path
/// `/Users/<name>/...` or `/home/<name>/...` reveals the OS account name,
/// which is often the same as a public handle — undesirable for users
/// who share their terminal.
///
/// **Do not use** this for paths that get persisted (sessions, audit log)
/// or sent to the LLM provider — those want full fidelity so they
/// resolve correctly across processes.
#[must_use]
pub fn display_path(path: &Path) -> String {
let Some(home) = dirs::home_dir() else {
return path.display().to_string();
};
if let Ok(rest) = path.strip_prefix(&home) {
if rest.as_os_str().is_empty() {
return "~".to_string();
}
// Render with the platform-correct separator after the tilde.
let sep = std::path::MAIN_SEPARATOR;
return format!("~{sep}{}", rest.display());
}
path.display().to_string()
}
/// Estimate the total character count across message content blocks.
#[must_use]
pub fn estimate_message_chars(messages: &[Message]) -> usize {
@@ -205,3 +232,74 @@ pub fn estimate_message_chars(messages: &[Message]) -> usize {
}
total
}
#[cfg(test)]
mod tests {
use super::display_path;
use std::path::PathBuf;
/// Save and restore $HOME inside one test so a panic anywhere can't
/// poison sibling tests that read the env var.
fn with_home<R>(home: &str, f: impl FnOnce() -> R) -> R {
let prev = std::env::var_os("HOME");
// SAFETY: tests in this crate are run single-threaded with respect
// to env-var mutation by the integration harness, and we restore
// immediately after the closure.
unsafe { std::env::set_var("HOME", home) };
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
match prev {
Some(v) => unsafe { std::env::set_var("HOME", v) },
None => unsafe { std::env::remove_var("HOME") },
}
match result {
Ok(v) => v,
Err(p) => std::panic::resume_unwind(p),
}
}
#[test]
fn display_path_contracts_home_prefix() {
with_home("/Users/alice", || {
assert_eq!(
display_path(&PathBuf::from("/Users/alice/projects/foo")),
format!(
"~{}projects{}foo",
std::path::MAIN_SEPARATOR,
std::path::MAIN_SEPARATOR
),
);
});
}
#[test]
fn display_path_returns_bare_tilde_for_home_itself() {
with_home("/Users/alice", || {
assert_eq!(display_path(&PathBuf::from("/Users/alice")), "~");
});
}
#[test]
fn display_path_leaves_unrelated_paths_alone() {
with_home("/Users/alice", || {
// Different user — must not get rewritten or share the tilde.
assert_eq!(
display_path(&PathBuf::from("/Users/bob/Code")),
"/Users/bob/Code".to_string()
);
// System path must stay absolute.
assert_eq!(display_path(&PathBuf::from("/etc/hosts")), "/etc/hosts");
});
}
#[test]
fn display_path_does_not_match_username_prefix() {
// Regression guard: a directory named like the user's home
// *prefix* but not under it must not get rewritten.
with_home("/Users/alice", || {
assert_eq!(
display_path(&PathBuf::from("/Users/alice2/work")),
"/Users/alice2/work"
);
});
}
}