Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions crates/config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3249,6 +3249,17 @@ pub fn codewhale_home() -> Result<PathBuf> {
Ok(home.join(CODEWHALE_APP_DIR))
}

fn explicit_codewhale_home() -> Option<PathBuf> {
std::env::var("CODEWHALE_HOME").ok().and_then(|val| {
let trimmed = val.trim();
if trimmed.is_empty() {
None
} else {
Some(PathBuf::from(trimmed))
}
})
}

/// Resolve the legacy DeepSeek home directory (`$HOME/.deepseek`).
///
/// Always returns the legacy path regardless of whether it exists.
Expand Down Expand Up @@ -3305,6 +3316,9 @@ fn ensure_safe_state_subdir(subdir: &str) -> Result<()> {
pub fn resolve_state_dir(subdir: &str) -> Result<PathBuf> {
ensure_safe_state_subdir(subdir)?;
let primary = codewhale_home()?.join(subdir);
if explicit_codewhale_home().is_some() {
return Ok(primary);
}
if primary.exists() {
return Ok(primary);
}
Expand Down Expand Up @@ -3342,6 +3356,9 @@ fn migrate_legacy_state_dir(primary: &Path, subdir: &str) -> Result<()> {
if primary.exists() || subdir == "." || subdir.is_empty() {
return Ok(());
}
if explicit_codewhale_home().is_some() {
return Ok(());
}
let legacy = match legacy_deepseek_home() {
Ok(home) => home.join(subdir),
Err(_) => return Ok(()),
Expand Down Expand Up @@ -3596,6 +3613,9 @@ pub fn default_config_path() -> Result<PathBuf> {
// Prefer ~/.codewhale/config.toml when it exists (fresh install or
// migrated), otherwise fall back to ~/.deepseek/config.toml.
let primary = codewhale_home()?.join(CONFIG_FILE_NAME);
if explicit_codewhale_home().is_some() {
return Ok(primary);
}
if primary.exists() {
return Ok(primary);
}
Expand Down Expand Up @@ -3632,6 +3652,9 @@ pub fn migrate_config_if_needed() -> Result<Option<ConfigMigration>> {
if primary.exists() {
return Ok(None);
}
if explicit_codewhale_home().is_some() {
return Ok(None);
}
let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME);
if !legacy.exists() {
return Ok(None);
Expand Down
84 changes: 79 additions & 5 deletions crates/config/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1911,7 +1911,7 @@ fn migrate_config_reports_copied_legacy_path() {
unsafe {
env::set_var("HOME", &home);
env::set_var("USERPROFILE", &home);
env::set_var("CODEWHALE_HOME", &primary_dir);
env::remove_var("CODEWHALE_HOME");
}

let migration = migrate_config_if_needed()
Expand Down Expand Up @@ -1962,9 +1962,9 @@ impl Drop for StateEnvRestore {
}
}

/// Points `HOME`/`USERPROFILE`/`CODEWHALE_HOME` at a fresh temp tree so
/// `codewhale_home()` -> `<home>/.codewhale` and `legacy_deepseek_home()`
/// -> `<home>/.deepseek`. Env is restored on drop.
/// Points `HOME`/`USERPROFILE` at a fresh temp tree so `codewhale_home()` ->
/// `<home>/.codewhale` and `legacy_deepseek_home()` -> `<home>/.deepseek`.
/// Env is restored on drop.
struct StateDirEnv {
home: PathBuf,
_restore: StateEnvRestore,
Expand All @@ -1985,7 +1985,7 @@ impl StateDirEnv {
unsafe {
env::set_var("HOME", &home);
env::set_var("USERPROFILE", &home);
env::set_var("CODEWHALE_HOME", home.join(CODEWHALE_APP_DIR));
env::remove_var("CODEWHALE_HOME");
}
Self {
home,
Expand Down Expand Up @@ -2087,6 +2087,80 @@ fn resolve_state_dir_still_finds_legacy_for_backfill() {
let _ = fs::remove_dir_all(&state_env.home);
}

#[test]
fn explicit_codewhale_home_does_not_read_or_migrate_legacy_state() {
let _lock = env_lock();
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos();
let state_env = StateDirEnv::install(unique);
let explicit = state_env.home.join("isolated-codewhale-home");
// Safety: test-only environment mutation is serialized by env_lock().
unsafe {
env::set_var("CODEWHALE_HOME", &explicit);
}

fs::create_dir_all(state_env.legacy("catalog")).expect("legacy dir");
fs::write(state_env.legacy("catalog").join("legacy.json"), b"legacy").expect("legacy file");

assert_eq!(
resolve_state_dir("catalog").expect("resolve"),
explicit.join("catalog")
);
assert_eq!(
ensure_state_dir("catalog").expect("ensure"),
explicit.join("catalog")
);
assert!(
!explicit.join("catalog").join("legacy.json").exists(),
"explicit CODEWHALE_HOME must not copy ambient legacy state"
);
assert!(
state_env.legacy("catalog").join("legacy.json").exists(),
"ambient legacy state should remain untouched"
);
let _ = fs::remove_dir_all(&state_env.home);
}

#[test]
fn explicit_codewhale_home_does_not_read_or_migrate_legacy_config() {
let _lock = env_lock();
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos();
let state_env = StateDirEnv::install(unique);
let explicit = state_env.home.join("isolated-codewhale-home");
let legacy_config = state_env.home.join(LEGACY_APP_DIR).join(CONFIG_FILE_NAME);
fs::create_dir_all(legacy_config.parent().expect("legacy parent")).expect("legacy dir");
fs::write(&legacy_config, b"provider = \"deepseek\"\n").expect("legacy config");
// Safety: test-only environment mutation is serialized by env_lock().
unsafe {
env::set_var("CODEWHALE_HOME", &explicit);
}

assert_eq!(
default_config_path().expect("default config"),
explicit.join(CONFIG_FILE_NAME)
);
assert!(
migrate_config_if_needed()
.expect("migration check")
.is_none(),
"explicit CODEWHALE_HOME should not import ambient legacy config"
);
assert!(
!explicit.join(CONFIG_FILE_NAME).exists(),
"explicit CODEWHALE_HOME must stay isolated until caller writes config"
);
assert!(
legacy_config.exists(),
"legacy config should remain untouched"
);
let _ = fs::remove_dir_all(&state_env.home);
}

#[test]
fn state_resolvers_reject_path_traversal_subdirs() {
// Defense against path injection (#3240 hardening): the public state
Expand Down
20 changes: 13 additions & 7 deletions crates/secrets/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

use std::collections::HashMap;
use std::fs;
#[cfg(unix)]
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};

Expand Down Expand Up @@ -480,22 +482,26 @@ impl FileKeyringStore {
}
}
let body = serde_json::to_string_pretty(blob)?;
fs::write(&self.path, body)?;
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
use std::os::unix::fs::PermissionsExt;
// Best-effort 0o600 — matches the parent-dir chmod above which
// is also `let _ = ...`. Filesystems that don't support Unix
// chmod (Docker bind-mounts of NTFS, network shares — #897)
// would otherwise fail the whole save here even though the
// blob already wrote successfully. The host's native ACLs
// are doing access control in those environments.
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&self.path)?;
file.write_all(body.as_bytes())?;
file.sync_all()?;
if let Ok(meta) = fs::metadata(&self.path) {
let mut perms = meta.permissions();
perms.set_mode(0o600);
let _ = fs::set_permissions(&self.path, perms);
}
Comment on lines 497 to 501

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

To avoid a potential Time-of-Check to Time-of-Use (TOCTOU) race condition and unnecessary path resolution syscalls, perform the metadata and permission operations directly on the opened file descriptor rather than querying the file path again.

Suggested change
if let Ok(meta) = fs::metadata(&self.path) {
let mut perms = meta.permissions();
perms.set_mode(0o600);
let _ = fs::set_permissions(&self.path, perms);
}
if let Ok(meta) = file.metadata() {
let mut perms = meta.permissions();
perms.set_mode(0o600);
let _ = file.set_permissions(perms);
}

}
#[cfg(not(unix))]
fs::write(&self.path, body)?;
Ok(())
}
}
Expand Down
68 changes: 66 additions & 2 deletions crates/state/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -305,8 +305,11 @@ impl StateStore {
}

fn conn(&self) -> Result<Connection> {
Connection::open(&self.db_path)
.with_context(|| format!("failed to open state db {}", self.db_path.display()))
let conn = Connection::open(&self.db_path)
.with_context(|| format!("failed to open state db {}", self.db_path.display()))?;
conn.pragma_update(None, "foreign_keys", "ON")
.context("failed to enable SQLite foreign keys")?;
Ok(conn)
}

fn init_schema(&self) -> Result<()> {
Expand Down Expand Up @@ -1867,6 +1870,67 @@ mod tests {
assert!(err.to_string().contains("thread missing-thread not found"));
}

#[test]
fn delete_thread_cascades_child_state() {
let store = temp_state_store("thread-delete-cascade");
store
.upsert_thread(&test_thread("thread-1"))
.expect("upsert thread");
store
.persist_dynamic_tools(
"thread-1",
&[DynamicToolRecord {
position: 0,
name: "custom_tool".to_string(),
description: Some("test".to_string()),
input_schema: serde_json::json!({"type": "object"}),
}],
)
.expect("dynamic tools");
store
.append_message("thread-1", "user", "hello", None)
.expect("append message");
store
.save_checkpoint("thread-1", "checkpoint-1", &serde_json::json!({"ok": true}))
.expect("checkpoint");
store
.upsert_thread_goal(&test_goal("thread-1", "Clean up children"))
.expect("goal");

store.delete_thread("thread-1").expect("delete thread");

assert!(
store
.get_thread("thread-1")
.expect("read deleted thread")
.is_none()
);
assert!(
store
.list_messages("thread-1", None)
.expect("messages after delete")
.is_empty()
);
assert!(
store
.list_checkpoints("thread-1", None)
.expect("checkpoints after delete")
.is_empty()
);
assert!(
store
.get_dynamic_tools("thread-1")
.expect("dynamic tools after delete")
.is_empty()
);
assert!(
store
.get_thread_goal("thread-1")
.expect("goal after delete")
.is_none()
);
}

#[test]
fn record_thread_goal_usage_accumulates_tokens_and_time() {
let store = temp_state_store("thread-goal-usage");
Expand Down
Loading
Loading