fix(config): keep credential writes user-global when the ambient config is repo-scoped

With CODEWHALE_CONFIG_PATH (or the legacy var) pointing at a workspace's
.codewhale/config.toml, API keys, auth_mode markers, and oauth/external
credential pointers were written into that repo's plaintext file and were
invisible from every other repo. Credential writes now classify the
resolved config path and redirect to the user-global document when it is
workspace-scoped; an explicit CODEWHALE_HOME stays authoritative and
non-credential settings keep their current scoping.

Refs #5045 (secret-store unification and CWC parity remain open there).

Agent-assisted: implemented and tested with Claude Code.
https://claude.ai/code/session_01AAEuFJrqcMqF1oztnjZeMy
This commit is contained in:
Hmbown
2026-08-01 17:42:30 -07:00
parent d44b18eb4f
commit fb1b05c7c4
2 changed files with 321 additions and 8 deletions
+187
View File
@@ -4725,6 +4725,193 @@ fn config_path_from_env_value(path: &str) -> Result<Option<PathBuf>> {
}
}
/// Whether `path` names a workspace-scoped config document —
/// `<repo>/.codewhale/config.toml` (or the legacy `.deepseek` layout) inside a
/// checkout — rather than a user-global config file.
///
/// Credential writes (api_key values, `auth_mode` markers, oauth/external
/// credential pointers) must never target such a document: a key saved while
/// working in one repo would be invisible from every other repo, and the repo
/// file stores it in plaintext where it is easy to commit by accident (#5045).
///
/// A path is classified workspace-scoped only when its parent directory is a
/// `.codewhale`/`.deepseek` app dir outside the user's home AND the document
/// belongs to a workspace: it is relative (resolves against the process cwd),
/// its base directory contains the process cwd, or its base directory is a
/// checkout (has a `.git` entry). An explicit `$CODEWHALE_HOME` config is
/// user-global wherever that home points, even when the directory itself
/// happens to be named `.codewhale`; other custom locations (for example
/// `CODEWHALE_CONFIG_PATH=~/team.toml` or an isolated test directory) stay
/// honored as deliberate user-scoped choices.
#[must_use]
pub fn config_path_is_workspace_scoped(path: &Path) -> bool {
config_path_is_workspace_scoped_with_context(
path,
codewhale_paths::codewhale_home_override().as_deref(),
codewhale_paths::user_home().as_deref(),
std::env::current_dir().ok().as_deref(),
)
}
/// Environment-free core of [`config_path_is_workspace_scoped`], split out so
/// scope classification is testable without mutating process-global state.
fn config_path_is_workspace_scoped_with_context(
path: &Path,
explicit_codewhale_home: Option<&Path>,
user_home: Option<&Path>,
current_dir: Option<&Path>,
) -> bool {
if let Some(home) = explicit_codewhale_home
&& same_lexical_or_canonical_path(path, &home.join(CONFIG_FILE_NAME))
{
return false;
}
let Some(parent) = path.parent() else {
return false;
};
let parent_is_app_dir = parent
.file_name()
.and_then(OsStr::to_str)
.is_some_and(|name| name == CODEWHALE_APP_DIR || name == LEGACY_APP_DIR);
if !parent_is_app_dir {
return false;
}
let Some(base) = parent.parent() else {
return true;
};
if let Some(home) = user_home
&& same_lexical_or_canonical_path(base, home)
{
return false;
}
if path.is_relative() {
// Resolves against the process cwd: repo-scoped by construction.
return true;
}
// The document belongs to the workspace the process is sitting in…
if let Some(cwd) = current_dir
&& canonicalize_or_keep(cwd).starts_with(canonicalize_or_keep(base))
{
return true;
}
// …or to some other checkout (a `.git` entry beside the app dir).
base.join(".git").exists()
}
/// Lexical equality first, canonical equality as a fallback so an existing
/// path still matches through symlinked parents (e.g. `/tmp` on macOS).
fn same_lexical_or_canonical_path(a: &Path, b: &Path) -> bool {
a == b || canonicalize_or_keep(a) == canonicalize_or_keep(b)
}
fn canonicalize_or_keep(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}
#[cfg(test)]
mod credential_scope_tests {
use super::config_path_is_workspace_scoped_with_context;
use std::path::Path;
#[test]
fn config_inside_current_workspace_is_workspace_scoped() {
let temp = tempfile::tempdir().expect("tempdir");
let repo = temp.path().join("repo");
let cwd = repo.join("nested/dir");
for app_dir in [".codewhale", ".deepseek"] {
let config = repo.join(app_dir).join("config.toml");
assert!(
config_path_is_workspace_scoped_with_context(
&config,
None,
Some(Path::new("/home/user")),
Some(&cwd),
),
"{} should be workspace-scoped when cwd sits inside the repo",
config.display()
);
}
}
#[test]
fn relative_app_dir_config_is_workspace_scoped() {
assert!(config_path_is_workspace_scoped_with_context(
Path::new(".codewhale/config.toml"),
None,
Some(Path::new("/home/user")),
Some(Path::new("/somewhere/else")),
));
}
#[test]
fn checkout_config_outside_cwd_is_workspace_scoped_via_git_marker() {
let temp = tempfile::tempdir().expect("tempdir");
let repo = temp.path().join("repo");
std::fs::create_dir_all(repo.join(".git")).expect("git marker");
std::fs::create_dir_all(repo.join(".codewhale")).expect("app dir");
assert!(config_path_is_workspace_scoped_with_context(
&repo.join(".codewhale/config.toml"),
None,
Some(Path::new("/home/user")),
Some(Path::new("/somewhere/else")),
));
}
#[test]
fn user_global_and_custom_locations_are_not_workspace_scoped() {
let home = Path::new("/home/user");
let elsewhere = Some(Path::new("/somewhere/else"));
for global_config in [
"/home/user/.codewhale/config.toml",
"/home/user/.deepseek/config.toml",
"/home/user/team-config.toml",
"/etc/codewhale/config.toml",
] {
assert!(
!config_path_is_workspace_scoped_with_context(
Path::new(global_config),
None,
Some(home),
elsewhere,
),
"{global_config} should stay user-global"
);
}
// An isolated app-dir-shaped location with no workspace relationship
// (no cwd ancestry, no checkout marker) stays honored: test harnesses
// and deliberate overrides point there.
let temp = tempfile::tempdir().expect("tempdir");
assert!(!config_path_is_workspace_scoped_with_context(
&temp.path().join(".codewhale/config.toml"),
None,
Some(home),
elsewhere,
));
}
#[test]
fn explicit_codewhale_home_config_is_user_global_even_when_dir_is_app_named() {
let temp = tempfile::tempdir().expect("tempdir");
let repo = temp.path().join("repo");
let explicit = repo.join(".codewhale");
// Even with cwd inside the repo, the explicit CODEWHALE_HOME config is
// the user-global scope by definition.
assert!(!config_path_is_workspace_scoped_with_context(
&explicit.join("config.toml"),
Some(&explicit),
Some(Path::new("/home/user")),
Some(&repo),
));
// A different repo-scoped document is still workspace-scoped.
assert!(config_path_is_workspace_scoped_with_context(
&repo.join("other/.codewhale/config.toml"),
Some(&explicit),
Some(Path::new("/home/user")),
Some(&repo.join("other")),
));
}
}
#[must_use]
pub fn permissions_path_for_config_path(config_path: &Path) -> PathBuf {
config_sibling_path_unchecked(config_path, OsStr::new(PERMISSIONS_FILE_NAME))
+134 -8
View File
@@ -9055,6 +9055,33 @@ impl SavedCredential {
}
}
/// Resolve the config document for CREDENTIAL writes: api_key values,
/// `auth_mode` markers, and oauth/external-credential pointers.
///
/// Credentials are user-global — a key saved while working in one repo must be
/// visible from every other repo (#5045). The ambient
/// `CODEWHALE_CONFIG_PATH`/`DEEPSEEK_CONFIG_PATH` override can point at a
/// workspace-scoped document (`<repo>/.codewhale/config.toml`, plaintext and
/// easy to commit by accident), so credential writes that would land there are
/// rescoped to the user-global config instead. Non-credential settings keep
/// the ambient scoping, and callers that pass an explicit config path never
/// consult this resolver.
fn credential_config_path() -> Option<PathBuf> {
let resolved = default_config_path()?;
if !codewhale_config::config_path_is_workspace_scoped(&resolved) {
return Some(resolved);
}
let global = home_config_path();
if let Some(global) = global.as_ref() {
tracing::info!(
ambient = %resolved.display(),
global = %global.display(),
"rescoping credential write from workspace config to user-global config"
);
}
global
}
/// Save the active provider's API key.
///
/// The selected durable secret backend is attempted first. On success the
@@ -9080,7 +9107,7 @@ fn save_root_api_key_for_secret_slot(
anyhow::bail!("Refusing to save an empty API key.");
}
let path = default_config_path()
let path = credential_config_path()
.context("Failed to resolve config path: home directory not found.")?;
if let Some(secrets) = credential_secret_store_for_save() {
@@ -9191,7 +9218,7 @@ fn save_root_api_key_metadata_without_plaintext(
/// Write the `api_key` slot directly to `config.toml`.
fn save_api_key_to_config_file(api_key: &str) -> Result<PathBuf> {
let config_path = default_config_path()
let config_path = credential_config_path()
.context("Failed to resolve config path: home directory not found.")?;
ensure_parent_dir(&config_path)?;
@@ -9646,7 +9673,7 @@ fn save_api_key_for_identity_unlocked(
let api_key = api_key.trim();
anyhow::ensure!(!api_key.is_empty(), "Refusing to save an empty API key.");
let config_path = default_config_path()
let config_path = credential_config_path()
.context("Failed to resolve config path: home directory not found.")?;
ensure_parent_dir(&config_path)?;
@@ -9952,7 +9979,7 @@ pub(crate) fn persist_external_credential_consent_for_at(
)?;
let config_path = match config_path {
Some(path) => path.to_path_buf(),
None => default_config_path()
None => credential_config_path()
.context("Failed to resolve config path: home directory not found.")?,
};
ensure_parent_dir(&config_path)?;
@@ -10022,7 +10049,7 @@ pub(crate) fn revoke_external_credential_consent_for_at(
);
let config_path = match config_path {
Some(path) => path.to_path_buf(),
None => default_config_path()
None => credential_config_path()
.context("Failed to resolve config path: home directory not found.")?,
};
ensure_parent_dir(&config_path)?;
@@ -10196,8 +10223,9 @@ pub fn clear_api_key() -> Result<()> {
fn clear_api_key_unlocked() -> Result<()> {
// Strip api_key entries from config.toml, including provider-scoped
// nested entries. Clearing a config file must not trigger platform
// credential prompts.
let config_path = default_config_path()
// credential prompts. Clears target the same user-global document that
// credential saves write, so logout removes what login stored (#5045).
let config_path = credential_config_path()
.context("Failed to resolve config path: home directory not found.")?;
if !config_path.exists() {
@@ -10244,7 +10272,7 @@ pub fn clear_active_provider_api_key(provider: &str) -> Result<()> {
}
fn clear_active_provider_api_key_unlocked(provider: &str) -> Result<()> {
let config_path = default_config_path()
let config_path = credential_config_path()
.context("Failed to resolve config path: home directory not found.")?;
if !config_path.exists() {
@@ -10318,3 +10346,101 @@ fn clear_active_provider_api_key_unlocked(provider: &str) -> Result<()> {
#[cfg(test)]
mod tests;
/// #5045 regression coverage: credential writes must never land in a
/// workspace-scoped `.codewhale/config.toml`.
#[cfg(test)]
mod credential_scope_tests {
use super::*;
use crate::test_support::{EnvVarGuard, lock_test_env};
/// With the ambient config path pointing at a workspace-local
/// `.codewhale/config.toml` (a checkout the user works in), saving an
/// API key must write the user-global config under the isolated
/// `CODEWHALE_HOME`, never the project file. The `.git` marker stands in
/// for cwd-inside-the-workspace: chdir is process-global and unsafe in a
/// parallel test binary, and production classifies on either signal.
#[test]
fn api_key_save_rescopes_workspace_config_to_user_global() -> Result<()> {
let _lock = lock_test_env();
let temp = tempfile::tempdir()?;
let workspace = temp.path().join("repo");
fs::create_dir_all(workspace.join(".git"))?;
let project_dir = workspace.join(".codewhale");
fs::create_dir_all(&project_dir)?;
let project_config = project_dir.join("config.toml");
fs::write(&project_config, "approval_policy = \"never\"\n")?;
let user_home = temp.path().join("user-global-home");
let _home = EnvVarGuard::set("CODEWHALE_HOME", user_home.as_os_str());
let _config = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", project_config.as_os_str());
let _legacy_config = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
// No explicit secret backend: under cfg(test) the save takes the
// plaintext config-file path, which is exactly the surface this
// regression guards.
let _backend = EnvVarGuard::remove("CODEWHALE_SECRET_BACKEND");
let _legacy_backend = EnvVarGuard::remove("DEEPSEEK_SECRET_BACKEND");
let saved = save_api_key("workspace-rescope-test-key")?;
let global_config = user_home.join("config.toml");
assert_eq!(
saved,
SavedCredential::ConfigFile(global_config.clone()),
"credential save must surface the user-global destination"
);
let global = fs::read_to_string(&global_config)?;
assert!(
global.contains("workspace-rescope-test-key"),
"user-global config must hold the saved key: {global}"
);
let project = fs::read_to_string(&project_config)?;
assert!(
!project.contains("workspace-rescope-test-key"),
"credential leaked into workspace config: {project}"
);
assert!(
!project.contains("api_key"),
"workspace config must stay credential-free: {project}"
);
Ok(())
}
/// Provider-table saves go through the same resolver: an OpenRouter key
/// saved with a workspace-scoped ambient config path must land in the
/// user-global document.
#[test]
fn provider_api_key_save_rescopes_workspace_config_to_user_global() -> Result<()> {
let _lock = lock_test_env();
let temp = tempfile::tempdir()?;
let workspace = temp.path().join("repo");
fs::create_dir_all(workspace.join(".git"))?;
let project_dir = workspace.join(".codewhale");
fs::create_dir_all(&project_dir)?;
let project_config = project_dir.join("config.toml");
fs::write(&project_config, "approval_policy = \"never\"\n")?;
let user_home = temp.path().join("user-global-home");
let _home = EnvVarGuard::set("CODEWHALE_HOME", user_home.as_os_str());
let _config = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", project_config.as_os_str());
let _legacy_config = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
let _backend = EnvVarGuard::remove("CODEWHALE_SECRET_BACKEND");
let _legacy_backend = EnvVarGuard::remove("DEEPSEEK_SECRET_BACKEND");
let path = save_api_key_for(ApiProvider::Openrouter, "workspace-rescope-openrouter-key")?;
assert_eq!(
path,
user_home.join("config.toml"),
"provider save must report the user-global destination"
);
let global = fs::read_to_string(&path)?;
assert!(global.contains("workspace-rescope-openrouter-key"));
let project = fs::read_to_string(&project_config)?;
assert!(
!project.contains("workspace-rescope-openrouter-key"),
"credential leaked into workspace config: {project}"
);
Ok(())
}
}