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:
@@ -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
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user