From 838078430840170fbddfe8d0a5d3427da5d0eecd Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Fri, 8 May 2026 14:13:55 -0500 Subject: [PATCH] fix(security): tighten paths and output handling --- .github/workflows/ci.yml | 3 + .github/workflows/release.yml | 3 + crates/cli/src/lib.rs | 2 +- crates/config/src/lib.rs | 98 ++++++++++++++++++++++++++++--- crates/tui/src/main.rs | 36 ++++-------- crates/tui/src/mcp.rs | 27 ++++++++- crates/tui/src/runtime_api.rs | 40 +++++-------- crates/tui/src/runtime_threads.rs | 98 ++++++++++++++++++++++++++----- crates/tui/src/session_manager.rs | 39 +++++++++++- crates/tui/src/skills/mod.rs | 11 +++- crates/tui/src/tui/ui.rs | 4 +- crates/tui/src/tui/ui/tests.rs | 5 +- web/lib/content-watch.ts | 76 ++++++++++++++++++++++-- 13 files changed, 352 insertions(+), 90 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00c8c110..3b324d45 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,9 @@ on: schedule: - cron: '31 6 * * 1' +permissions: + contents: read + env: CARGO_TERM_COLOR: always RUSTFLAGS: -Dwarnings diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0eef3317..1ec2916a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,9 @@ on: required: true type: string +permissions: + contents: read + env: CARGO_TERM_COLOR: always RUSTFLAGS: -Dwarnings diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 1ee04c95..24937c1a 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -1088,7 +1088,7 @@ fn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) - fn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> { match command { ConfigCommand::Get { key } => { - if let Some(value) = store.config.get_value(&key) { + if let Some(value) = store.config.get_display_value(&key) { println!("{value}"); return Ok(()); } diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index ddfe7908..ee744a4d 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; use std::fs; #[cfg(unix)] use std::io::Write; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use std::sync::OnceLock; use anyhow::{Context, Result, bail}; @@ -451,6 +451,17 @@ impl ConfigToml { } } + #[must_use] + pub fn get_display_value(&self, key: &str) -> Option { + self.get_value(key).map(|value| { + if is_sensitive_config_key(key) { + redact_secret(&value) + } else { + value + } + }) + } + pub fn set_value(&mut self, key: &str, value: &str) -> Result<()> { match key { "provider" => { @@ -1229,16 +1240,19 @@ pub fn default_secrets() -> &'static Secrets { } pub fn resolve_config_path(explicit: Option) -> Result { - if let Some(path) = explicit { - return Ok(path); - } - if let Ok(path) = std::env::var("DEEPSEEK_CONFIG_PATH") { + let path = if let Some(path) = explicit { + path + } else if let Ok(path) = std::env::var("DEEPSEEK_CONFIG_PATH") { let trimmed = path.trim(); if !trimmed.is_empty() { - return Ok(PathBuf::from(trimmed)); + PathBuf::from(trimmed) + } else { + return default_config_path(); } - } - default_config_path() + } else { + return default_config_path(); + }; + normalize_config_file_path(path) } pub fn default_config_path() -> Result { @@ -1307,6 +1321,35 @@ fn redact_secret(secret: &str) -> String { format!("{prefix}***{suffix}") } +#[must_use] +pub fn is_sensitive_config_key(key: &str) -> bool { + matches!( + key, + "api_key" | "auth.chatgpt_access_token" | "auth.device_code_session" + ) || key.ends_with(".api_key") +} + +fn normalize_config_file_path(path: PathBuf) -> Result { + if path.as_os_str().is_empty() { + bail!("config path cannot be empty"); + } + if path + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + bail!("config path cannot contain '..' components"); + } + if path.file_name().is_none() { + bail!("config path must include a file name"); + } + if path.is_absolute() { + return Ok(path); + } + Ok(std::env::current_dir() + .context("failed to resolve current directory for config path")? + .join(path)) +} + #[derive(Debug, Clone, Default)] struct EnvRuntimeOverrides { provider: Option, @@ -1798,6 +1841,38 @@ mod tests { assert_eq!(values.get("api_key").map(String::as_str), Some("********")); } + #[test] + fn get_display_value_redacts_sensitive_keys() { + let mut config = ConfigToml { + api_key: Some("sk-deepseek-secret".to_string()), + chatgpt_access_token: Some("chatgpt-access-secret".to_string()), + ..ConfigToml::default() + }; + config.providers.openrouter.api_key = Some("openrouter-secret-value".to_string()); + config.model = Some("deepseek-v4-pro".to_string()); + + assert_eq!( + config.get_display_value("api_key").as_deref(), + Some("sk-d***cret") + ); + assert_eq!( + config + .get_display_value("auth.chatgpt_access_token") + .as_deref(), + Some("chat***cret") + ); + assert_eq!( + config + .get_display_value("providers.openrouter.api_key") + .as_deref(), + Some("open***alue") + ); + assert_eq!( + config.get_display_value("model").as_deref(), + Some("deepseek-v4-pro") + ); + } + #[test] fn list_values_redacts_unicode_api_key_without_byte_slicing() { let config = ConfigToml { @@ -1813,6 +1888,13 @@ mod tests { ); } + #[test] + fn normalize_config_file_path_rejects_traversal() { + let err = normalize_config_file_path(PathBuf::from("../config.toml")) + .expect_err("traversal path should fail"); + assert!(format!("{err:#}").contains("cannot contain '..'")); + } + #[cfg(unix)] #[test] fn save_clamps_existing_config_permissions() { diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 1557a25a..a9a32814 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -1732,11 +1732,10 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt std::io::stdout().flush().ok(); match test_api_connectivity(config).await { - Ok(model) => { + Ok(()) => { println!( - "\r {} API connection successful (model: {})", - "✓".truecolor(aqua_r, aqua_g, aqua_b), - model + "\r {} API connection successful", + "✓".truecolor(aqua_r, aqua_g, aqua_b) ); } Err(e) => { @@ -2544,7 +2543,7 @@ async fn run_models(config: &Config, args: ModelsArgs) -> Result<()> { } /// Test API connectivity by making a minimal request -async fn test_api_connectivity(config: &Config) -> Result { +async fn test_api_connectivity(config: &Config) -> Result<()> { use crate::client::DeepSeekClient; use crate::models::{ContentBlock, Message, MessageRequest}; @@ -2576,7 +2575,7 @@ async fn test_api_connectivity(config: &Config) -> Result { // Use tokio timeout to catch hanging requests let timeout_duration = std::time::Duration::from_secs(15); match tokio::time::timeout(timeout_duration, client.create_message(request)).await { - Ok(Ok(_response)) => Ok(model), + Ok(Ok(_response)) => Ok(()), Ok(Err(e)) => Err(e), Err(_) => anyhow::bail!("Request timeout after 15 seconds"), } @@ -3742,17 +3741,13 @@ fn recover_interrupted_checkpoint_for_resume(launch_workspace: &Path) -> Option< // 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 \ - in {}.", - &session_id_for_notice.chars().take(8).collect::(), + "Note: an interrupted session from another workspace ({}) is \ + available. Run `deepseek sessions` to list saved sessions. Starting \ + fresh in {}.", session_workspace.display(), - session_id_for_notice, launch_workspace.display(), ); return None; @@ -3786,27 +3781,22 @@ fn preserve_interrupted_checkpoint_for_explicit_resume(launch_workspace: &Path) return; }; - let session_id = session.metadata.id.clone(); let session_workspace = session.metadata.workspace.clone(); let _ = manager.save_session(&session); let _ = manager.clear_checkpoint(); let age_str = checkpoint_age_label(age); - let short_id = session_id.chars().take(8).collect::(); if session_manager::workspace_scope_matches(&session_workspace, launch_workspace) { eprintln!( - "Found an in-flight session snapshot ({age_str}, {short_id}…). \ - Starting a new session. Run `deepseek resume {session_id}` or \ - `deepseek --continue` to resume it." + "Found an in-flight session snapshot ({age_str}). Starting a new \ + session. Run `deepseek --continue` to resume it." ); } else { eprintln!( - "Note: an interrupted session ({short_id}…) from another workspace ({}) \ - is available. Run `deepseek resume {}` from there to recover it, or \ - use `deepseek sessions` to list all saved sessions. Starting fresh \ - in {}.", + "Note: an interrupted session from another workspace ({}) is \ + available. Run `deepseek sessions` to list saved sessions. Starting \ + fresh in {}.", session_workspace.display(), - session_id, launch_workspace.display(), ); } diff --git a/crates/tui/src/mcp.rs b/crates/tui/src/mcp.rs index b9356c60..b59553d6 100644 --- a/crates/tui/src/mcp.rs +++ b/crates/tui/src/mcp.rs @@ -7,7 +7,7 @@ use std::collections::{HashMap, VecDeque}; use std::fs; -use std::path::Path; +use std::path::{Component, Path}; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Duration; @@ -24,6 +24,19 @@ use crate::utils::write_atomic; /// Bytes of a non-2xx response body to surface in connection errors. const ERROR_BODY_PREVIEW_BYTES: usize = 200; +fn validate_mcp_config_path(path: &Path) -> Result<()> { + if path.as_os_str().is_empty() { + anyhow::bail!("MCP config path cannot be empty"); + } + if path + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + anyhow::bail!("MCP config path cannot contain '..' components"); + } + Ok(()) +} + /// Mask a URL so any embedded credentials in the userinfo portion (e.g. /// `https://user:secret@host`) are replaced with `***`. Failures fall back to /// the original string so we don't lose context — we never want masking to @@ -1046,6 +1059,7 @@ impl McpPool { /// Create a pool from a configuration file path pub fn from_config_path(path: &std::path::Path) -> Result { + validate_mcp_config_path(path)?; let config = if path.exists() { let contents = fs::read_to_string(path) .with_context(|| format!("Failed to read MCP config: {}", path.display()))?; @@ -1605,6 +1619,7 @@ pub struct McpManagerSnapshot { } pub fn load_config(path: &Path) -> Result { + validate_mcp_config_path(path)?; if !path.exists() { return Ok(McpConfig::default()); } @@ -1615,6 +1630,7 @@ pub fn load_config(path: &Path) -> Result { } pub fn save_config(path: &Path, cfg: &McpConfig) -> Result<()> { + validate_mcp_config_path(path)?; if let Some(parent) = path.parent() { fs::create_dir_all(parent).with_context(|| { format!("Failed to create MCP config directory {}", parent.display()) @@ -1971,6 +1987,15 @@ mod tests { assert_eq!(snapshot.servers[0].error.as_deref(), Some("disabled")); } + #[test] + fn test_mcp_config_rejects_traversal_path() { + let err = load_config(Path::new("../mcp.json")).expect_err("traversal path should fail"); + assert!( + format!("{err:#}").contains("cannot contain '..'"), + "got: {err:#}" + ); + } + #[test] fn test_mcp_config_manager_actions_round_trip() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index b01f3605..a828b9e4 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -1602,30 +1602,23 @@ fn run_git(workspace: &std::path::Path, args: &[&str]) -> Option { } fn resolve_skills_dir(config: &Config, workspace: &std::path::Path) -> PathBuf { - let agents_skills = workspace.join(".agents").join("skills"); - if agents_skills.exists() { - return agents_skills; - } - let local_skills = workspace.join("skills"); - if local_skills.exists() { - return local_skills; + let workspace = fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf()); + for candidate in [ + workspace.join(".agents").join("skills"), + workspace.join("skills"), + ] { + if let Ok(candidate) = fs::canonicalize(candidate) + && candidate.is_dir() + { + return candidate; + } } config.skills_dir() } fn load_mcp_config_or_default(path: &std::path::Path) -> Result { - if !path.exists() { - return Ok(McpConfig::default()); - } - let raw = fs::read_to_string(path).map_err(|e| { - ApiError::internal(format!("Failed to read MCP config {}: {e}", path.display())) - })?; - serde_json::from_str::(&raw).map_err(|e| { - ApiError::internal(format!( - "Failed to parse MCP config {}: {e}", - path.display() - )) - }) + crate::mcp::load_config(path) + .map_err(|e| ApiError::internal(format!("Failed to load MCP config: {e:#}"))) } #[derive(Debug, Deserialize)] @@ -3046,11 +3039,10 @@ mod tests { let root = std::env::temp_dir().join(format!("deepseek-session-resume-{}", Uuid::new_v4())); let sessions_dir = root.join("sessions"); fs::create_dir_all(&sessions_dir)?; - let session_id = "sess_test_resume"; let session = json!({ "schema_version": 1, "metadata": { - "id": session_id, + "id": "sess_test_resume", "title": "Test resume session", "created_at": "2025-01-01T00:00:00Z", "updated_at": "2025-01-01T00:10:00Z", @@ -3073,7 +3065,7 @@ mod tests { "system_prompt": null }); fs::write( - sessions_dir.join(format!("{session_id}.json")), + sessions_dir.join("sess_test_resume.json"), serde_json::to_string_pretty(&session)?, )?; @@ -3086,14 +3078,14 @@ mod tests { let resp = client .post(format!( - "http://{addr}/v1/sessions/{session_id}/resume-thread" + "http://{addr}/v1/sessions/sess_test_resume/resume-thread" )) .json(&json!({ "model": "deepseek-v4-pro" })) .send() .await?; assert_eq!(resp.status(), StatusCode::CREATED); let resumed: serde_json::Value = resp.json().await?; - assert_eq!(resumed["session_id"], session_id); + assert_eq!(resumed["session_id"], "sess_test_resume"); assert_eq!(resumed["message_count"], 2); let thread_id = resumed["thread_id"] diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index f0d6899f..64e15560 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -33,6 +33,24 @@ use crate::tui::app::AppMode; const EVENT_CHANNEL_CAPACITY: usize = 1024; const MAX_ACTIVE_THREADS_DEFAULT: usize = 8; const SUMMARY_LIMIT: usize = 280; + +fn validated_record_id<'a>(id: &'a str, label: &str) -> Result<&'a str> { + let trimmed = id.trim(); + if trimmed.is_empty() { + bail!("{label} cannot be empty"); + } + if trimmed != id { + bail!("{label} cannot contain leading or trailing whitespace"); + } + if !trimmed + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + bail!("{label} contains unsupported characters"); + } + Ok(trimmed) +} + /// Bumped to 2 for v0.6.6 — see issue #124. The persisted thread/turn/item /// records didn't change shape, but the live engine semantics did: cycle /// boundaries advance the `Session.cycle_count` and produce archived JSONL @@ -241,36 +259,43 @@ impl RuntimeThreadStore { }) } - fn thread_path(&self, thread_id: &str) -> PathBuf { - self.threads_dir.join(format!("{thread_id}.json")) + fn record_path(base: &Path, id: &str, extension: &str, label: &str) -> Result { + let id = validated_record_id(id, label)?; + Ok(base.join(format!("{id}.{extension}"))) } - fn turn_path(&self, turn_id: &str) -> PathBuf { - self.turns_dir.join(format!("{turn_id}.json")) + fn thread_path(&self, thread_id: &str) -> Result { + Self::record_path(&self.threads_dir, thread_id, "json", "thread id") } - fn item_path(&self, item_id: &str) -> PathBuf { - self.items_dir.join(format!("{item_id}.json")) + fn turn_path(&self, turn_id: &str) -> Result { + Self::record_path(&self.turns_dir, turn_id, "json", "turn id") } - fn events_path(&self, thread_id: &str) -> PathBuf { - self.events_dir.join(format!("{thread_id}.jsonl")) + fn item_path(&self, item_id: &str) -> Result { + Self::record_path(&self.items_dir, item_id, "json", "item id") + } + + fn events_path(&self, thread_id: &str) -> Result { + Self::record_path(&self.events_dir, thread_id, "jsonl", "thread id") } pub fn save_thread(&self, thread: &ThreadRecord) -> Result<()> { - write_json_atomic(&self.thread_path(&thread.id), thread) + write_json_atomic(&self.thread_path(&thread.id)?, thread) } pub fn save_turn(&self, turn: &TurnRecord) -> Result<()> { - write_json_atomic(&self.turn_path(&turn.id), turn) + validated_record_id(&turn.thread_id, "thread id")?; + write_json_atomic(&self.turn_path(&turn.id)?, turn) } pub fn save_item(&self, item: &TurnItemRecord) -> Result<()> { - write_json_atomic(&self.item_path(&item.id), item) + validated_record_id(&item.turn_id, "turn id")?; + write_json_atomic(&self.item_path(&item.id)?, item) } pub fn load_thread(&self, thread_id: &str) -> Result { - let path = self.thread_path(thread_id); + let path = self.thread_path(thread_id)?; let raw = fs::read_to_string(&path) .with_context(|| format!("Failed to read thread {}", path.display()))?; let record: ThreadRecord = serde_json::from_str(&raw) @@ -286,7 +311,7 @@ impl RuntimeThreadStore { } pub fn load_turn(&self, turn_id: &str) -> Result { - let path = self.turn_path(turn_id); + let path = self.turn_path(turn_id)?; let raw = fs::read_to_string(&path) .with_context(|| format!("Failed to read turn {}", path.display()))?; let record: TurnRecord = serde_json::from_str(&raw) @@ -302,7 +327,7 @@ impl RuntimeThreadStore { } pub fn load_item(&self, item_id: &str) -> Result { - let path = self.item_path(item_id); + let path = self.item_path(item_id)?; let raw = fs::read_to_string(&path) .with_context(|| format!("Failed to read item {}", path.display()))?; let record: TurnItemRecord = serde_json::from_str(&raw) @@ -345,6 +370,7 @@ impl RuntimeThreadStore { } pub fn list_turns_for_thread(&self, thread_id: &str) -> Result> { + validated_record_id(thread_id, "thread id")?; let mut out = Vec::new(); for entry in fs::read_dir(&self.turns_dir) .with_context(|| format!("Failed to read {}", self.turns_dir.display()))? @@ -374,6 +400,7 @@ impl RuntimeThreadStore { } pub fn list_items_for_turn(&self, turn_id: &str) -> Result> { + validated_record_id(turn_id, "turn id")?; let mut out = Vec::new(); for entry in fs::read_dir(&self.items_dir) .with_context(|| format!("Failed to read {}", self.items_dir.display()))? @@ -414,6 +441,15 @@ impl RuntimeThreadStore { event: impl Into, payload: Value, ) -> Result { + validated_record_id(thread_id, "thread id")?; + if let Some(turn_id) = turn_id { + validated_record_id(turn_id, "turn id")?; + } + if let Some(item_id) = item_id { + validated_record_id(item_id, "item id")?; + } + let path = self.events_path(thread_id)?; + let mut state = self.state.lock().await; let seq = state.next_seq; state.next_seq = state.next_seq.saturating_add(1); @@ -431,7 +467,6 @@ impl RuntimeThreadStore { payload, }; - let path = self.events_path(thread_id); let mut file = OpenOptions::new() .create(true) .append(true) @@ -451,7 +486,7 @@ impl RuntimeThreadStore { thread_id: &str, since_seq: Option, ) -> Result> { - let path = self.events_path(thread_id); + let path = self.events_path(thread_id)?; if !path.exists() { return Ok(Vec::new()); } @@ -3319,6 +3354,37 @@ mod tests { assert_eq!(CURRENT_RUNTIME_SCHEMA_VERSION, 2); } + #[test] + fn store_rejects_path_like_record_ids() { + let dir = test_runtime_dir(); + let store = RuntimeThreadStore::open(dir.clone()).expect("open store"); + + let err = store + .load_thread("../outside") + .expect_err("path traversal id should fail"); + assert!( + format!("{err:#}").contains("unsupported characters"), + "got: {err:#}" + ); + + let mut thread = sample_thread("thr_bad/id"); + let err = store + .save_thread(&thread) + .expect_err("path separator id should fail"); + assert!( + format!("{err:#}").contains("unsupported characters"), + "got: {err:#}" + ); + + thread.id = " thr_bad".to_string(); + let err = store + .save_thread(&thread) + .expect_err("whitespace id should fail"); + assert!(format!("{err:#}").contains("whitespace"), "got: {err:#}"); + + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn store_load_turn_rejects_newer_schema_version() { let dir = test_runtime_dir(); diff --git a/crates/tui/src/session_manager.rs b/crates/tui/src/session_manager.rs index 4c82ab1d..d3b4abf4 100644 --- a/crates/tui/src/session_manager.rs +++ b/crates/tui/src/session_manager.rs @@ -12,7 +12,7 @@ use crate::utils::write_atomic; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use uuid::Uuid; /// Maximum number of sessions to retain @@ -33,6 +33,31 @@ const fn default_queue_schema_version() -> u32 { CURRENT_QUEUE_SCHEMA_VERSION } +fn normalize_managed_dir(path: PathBuf) -> std::io::Result { + if path.as_os_str().is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "managed directory path cannot be empty", + )); + } + if path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::Prefix(_) | Component::RootDir + ) + }) && path.is_relative() + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "managed directory path cannot contain traversal components", + )); + } + if path.is_absolute() { + return Ok(path); + } + std::env::current_dir().map(|cwd| cwd.join(path)) +} + /// Persisted queued message for offline/degraded mode. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct QueuedSessionMessage { @@ -117,6 +142,7 @@ pub struct SavedSession { } /// Manager for session persistence operations +#[derive(Debug)] pub struct SessionManager { /// Directory where sessions are stored sessions_dir: PathBuf, @@ -145,6 +171,7 @@ impl SessionManager { /// Create a new `SessionManager` with the specified sessions directory pub fn new(sessions_dir: PathBuf) -> std::io::Result { + let sessions_dir = normalize_managed_dir(sessions_dir)?; // Ensure the sessions directory exists fs::create_dir_all(&sessions_dir)?; Ok(Self { sessions_dir }) @@ -1060,6 +1087,13 @@ mod tests { assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); } + #[test] + fn test_session_manager_rejects_relative_traversal_dir() { + let err = SessionManager::new(PathBuf::from("../sessions")) + .expect_err("relative traversal directory should fail"); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + } + #[test] fn test_truncate_title() { assert_eq!(truncate_title("Short", 50), "Short"); @@ -1234,8 +1268,7 @@ mod tests { .expect("present"); assert!( unscoped.session_id.is_none(), - "save with None must persist a missing session_id, got {:?}", - unscoped.session_id + "save with None must persist a missing session_id" ); } diff --git a/crates/tui/src/skills/mod.rs b/crates/tui/src/skills/mod.rs index 2750a784..2cc52725 100644 --- a/crates/tui/src/skills/mod.rs +++ b/crates/tui/src/skills/mod.rs @@ -103,7 +103,10 @@ impl SkillRegistry { #[must_use] pub fn discover(dir: &Path) -> Self { let mut registry = Self::default(); - if !dir.exists() { + let Ok(canonical_dir) = fs::canonicalize(dir) else { + return registry; + }; + if !canonical_dir.is_dir() { return registry; } @@ -403,8 +406,12 @@ pub fn skills_directories(workspace: &Path) -> Vec { fn existing_skill_dirs(candidates: impl IntoIterator) -> Vec { let mut out = Vec::new(); + let mut seen = HashSet::new(); for path in candidates { - if path.is_dir() && !out.iter().any(|p: &PathBuf| p == &path) { + let Ok(canonical_path) = fs::canonicalize(&path) else { + continue; + }; + if canonical_path.is_dir() && seen.insert(canonical_path) { out.push(path); } } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index eed12925..f15b3f6d 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -442,9 +442,7 @@ fn format_resume_hint(session_id: Option<&str>) -> Option { if session_id.is_empty() { return None; } - Some(format!( - "To continue this session, run deepseek resume {session_id}" - )) + Some("To continue this session, run deepseek --continue".to_string()) } fn terminal_probe_timeout(config: &Config) -> Duration { diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index f6952fd5..cdfcf4ad 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -20,10 +20,7 @@ use tempfile::TempDir; fn format_resume_hint_uses_canonical_resume_command() { assert_eq!( format_resume_hint(Some("019dd9d6-4f44-7c83-9863-59674a12b827")), - Some( - "To continue this session, run deepseek resume 019dd9d6-4f44-7c83-9863-59674a12b827" - .to_string() - ) + Some("To continue this session, run deepseek --continue".to_string()) ); } diff --git a/web/lib/content-watch.ts b/web/lib/content-watch.ts index ee1428a3..28385679 100644 --- a/web/lib/content-watch.ts +++ b/web/lib/content-watch.ts @@ -137,6 +137,75 @@ If nothing is drifted, return { "drifts": [] }. ${VOICE_CONSTRAINTS}`; +function startsWithAsciiCI(input: string, index: number, needle: string): boolean { + if (index + needle.length > input.length) return false; + return input.slice(index, index + needle.length).toLowerCase() === needle; +} + +function isWhitespace(c: string | undefined): boolean { + return c === " " || c === "\n" || c === "\r" || c === "\t" || c === "\f"; +} + +function tagNameBoundary(input: string, index: number): boolean { + const c = input[index]; + return c === undefined || c === ">" || c === "/" || isWhitespace(c); +} + +function findClosingRawTextTag(input: string, from: number, tagName: "script" | "style"): number { + const closePrefix = `", i + closePrefix.length); + return close === -1 ? input.length : close + 1; + } + } + return input.length; +} + +function collapseWhitespace(input: string): string { + let out = ""; + let pendingSpace = false; + for (const c of input) { + if (isWhitespace(c)) { + pendingSpace = out.length > 0; + continue; + } + if (pendingSpace) out += " "; + out += c; + pendingSpace = false; + } + return out.trim(); +} + +function stripHtmlForPrompt(input: string): string { + let out = ""; + for (let i = 0; i < input.length;) { + if (input[i] !== "<") { + out += input[i]; + i += 1; + continue; + } + + if (startsWithAsciiCI(input, i, "", i + 1); + i = openEnd === -1 ? input.length : findClosingRawTextTag(input, openEnd + 1, "script"); + continue; + } + if (startsWithAsciiCI(input, i, "", i + 1); + i = openEnd === -1 ? input.length : findClosingRawTextTag(input, openEnd + 1, "style"); + continue; + } + + out += " "; + const tagEnd = input.indexOf(">", i + 1); + i = tagEnd === -1 ? input.length : tagEnd + 1; + } + return collapseWhitespace(out).slice(0, 8000); +} + export async function runSemanticDrift(env: WatchEnv): Promise<{ ok: boolean; drafted: number; reason?: string }> { if (!env.CURATED_KV || !env.DEEPSEEK_API_KEY) { return { ok: false, drafted: 0, reason: "missing CURATED_KV or DEEPSEEK_API_KEY" }; @@ -160,11 +229,8 @@ export async function runSemanticDrift(env: WatchEnv): Promise<{ ok: boolean; dr return { ok: false, drafted: 0, reason: "no changelog or commits available" }; } - // Strip HTML tags + collapse whitespace to keep prompt size tractable. - const stripHtml = (h: string) => h.replace(//g, "").replace(//g, "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().slice(0, 8000); - - const homepageText = stripHtml(homepageHtml); - const docsText = stripHtml(docsHtml); + const homepageText = stripHtmlForPrompt(homepageHtml); + const docsText = stripHtmlForPrompt(docsHtml); const changelogHead = changelog.slice(0, 4000); const commitMsgs = commits.slice(0, 30).map((c) => `- ${c.sha.slice(0, 7)}: ${c.commit.message.split("\n")[0]}`).join("\n");