Root cause
Injection & unsafe execution introduced
feat(github): add `credential_command` setting for custom token retrieval (#8746)
How AI contributed: Direct introductionPublished Jun 23, 2026 in
jdx/mise
The AI-written change added the github.credential_command setting, which mise runs through sh -c and reads a GitHub token from. Because that setting is taken from the local mise.toml while settings are preloaded, ahead of any trust check, a malicious repository can supply its own shell command and have it executed with the user's privileges when mise loads that project. The fix marks the credential command as global-only for GitHub, Forgejo and GitLab, so local project configuration can no longer define it.
resolve_token() calls get_credential_command_token(), which runs the configured credential_command through sh -c, and the value is preloaded from the untrusted local mise.toml before the trust check runs. The fix adds a global_only flag in src/config/settings.rs and strips such settings from project configuration.
Root cause
feat(github): add `credential_command` setting for custom token retrieval (#8746)
Fix
fix(config): ignore local credential commands (#10356)
Case facts
@@ -306,6 +306,7 @@ pub enum TokenSource { EnvVar(&'static str), TokensFile, GhCli,+ CredentialCommand, GitCredential, } @@ -315,6 +316,7 @@ impl fmt::Display for TokenSource { TokenSource::EnvVar(name) => write!(f, "{name}"), TokenSource::TokensFile => write!(f, "github_tokens.toml"), TokenSource::GhCli => write!(f, "gh CLI (hosts.yml)"),+ TokenSource::CredentialCommand => write!(f, "credential_command"), TokenSource::GitCredential => write!(f, "git credential fill"), } }@@ -337,7 +339,7 @@ fn canonical_host(host: Option<&str>) -> Option<&str> { /// 2. `MISE_GITHUB_TOKEN` / `GITHUB_API_TOKEN` / `GITHUB_TOKEN` env vars /// 3. `github_tokens.toml` (per-host) /// 4. gh CLI token (from `hosts.yml`)-/// 5. `git credential fill` (if enabled)+/// 5. `credential_command` (if set) OR `git credential fill` (if enabled) pub fn resolve_token(host: &str) -> Option<(String, TokenSource)> { let settings = Settings::get(); @@ -381,8 +383,14 @@ pub fn resolve_token(host: &str) -> Option<(String, TokenSource)> { return Some((token.clone(), TokenSource::GhCli)); } - // 5. git credential fill- if settings.github.use_git_credentials+ // 5. credential_command OR git credential fill+ let credential_command = &settings.github.credential_command;+ if !credential_command.is_empty() {+ if let Some(token) = get_credential_command_token(credential_command, lookup_host) {+ return Some((token, TokenSource::CredentialCommand));+ }+ debug!("credential_command returned no token; git credential fill will not be tried");+ } else if settings.github.use_git_credentials && let Some(token) = get_git_credential_token(lookup_host) { return Some((token, TokenSource::GitCredential));@@ -535,6 +543,59 @@ struct GhHostEntry { oauth_token: Option<String>, } +// ── credential_command ──────────────────────────────────────────────++/// Cache for tokens obtained from `credential_command`.+/// Maps hostname to the token (or None if the command failed).+static CREDENTIAL_COMMAND_CACHE: Lazy<std::sync::Mutex<HashMap<String, Option<String>>>> =+ Lazy::new(Default::default);++/// Get a GitHub token by running the user's `credential_command` setting.+/// The host is passed as `$1` to the command. Results are cached per host.+fn get_credential_command_token(cmd: &str, host: &str) -> Option<String> {+ let mut cache = CREDENTIAL_COMMAND_CACHE+ .lock()+ .expect("CREDENTIAL_COMMAND_CACHE mutex poisoned");+ if let Some(token) = cache.get(host) {+ return token.clone();+ }+ let result = std::process::Command::new("sh")+ .arg("-c")+ .arg(cmd)+ .arg("mise-credential-helper") // $0+ .arg(host) // $1+ .env("GIT_TERMINAL_PROMPT", "0")+ .stdin(std::process::Stdio::null())+ .stdout(std::process::Stdio::piped())+ .stderr(std::process::Stdio::piped())+ .output()+ .ok()+ .and_then(|output| {+ if !output.status.success() {+ if let Ok(err) = String::from_utf8(output.stderr)+ && !err.trim().is_empty()+ {+ debug!("credential_command stderr: {}", err.trim());+ }+ return None;+ }+ String::from_utf8(output.stdout)+ .ok()+ .map(|s| s.trim().to_string())+ .filter(|s| !s.is_empty())+ });+ trace!(+ "credential_command for {host}: {}",+ if result.is_some() {@@ -611,6 +611,24 @@ description = "Timeout in seconds for HTTP requests to fetch new tool versions i env = "MISE_FETCH_REMOTE_VERSIONS_TIMEOUT" type = "Duration" +[github.credential_command]+default = ""+description = "Shell command to run to obtain a GitHub token for mise."+docs = """+When set, mise executes this command via `sh -c` and reads the token+from stdout. The hostname is passed as `$1`, making the command+host-aware for GitHub Enterprise environments. This replaces the+`git credential fill` fallback and does not require+`github.use_git_credentials` to be enabled.++The command should print a single token to stdout. Trailing whitespace+is trimmed. Results are cached per host per session.++Example: `op read "op://Private/GitHub Token/credential"`+"""+env = "MISE_GITHUB_CREDENTIAL_COMMAND"+type = "String"+ [github.gh_cli_tokens] default = true description = "Read GitHub tokens from the gh CLI's hosts.yml config."@@ -739,6 +739,11 @@ "type": "object", "additionalProperties": false, "properties": {+ "credential_command": {+ "default": "",+ "description": "Shell command to run to obtain a GitHub token for mise.",+ "type": "string"+ }, "gh_cli_tokens": { "default": true, "description": "Read GitHub tokens from the gh CLI's hosts.yml config.",@@ -51,6 +51,7 @@ pub struct SettingsMeta { pub deprecated: Option<&'static str>, pub deprecated_warn_at: Option<&'static str>, pub deprecated_remove_at: Option<&'static str>,+ pub global_only: bool, } #[derive(@@ -260,6 +261,40 @@ fn normalize_hidden_config_aliases(mut partial: SettingsPartial) -> SettingsPart partial } +fn strip_local_only_settings(settings: &mut toml::Table, path: &Path, is_global: bool) {+ if is_global {+ return;+ }++ for key in SETTINGS_META+ .iter()+ .filter_map(|(key, meta)| meta.global_only.then_some(*key))+ {+ if let Some(value) = remove_nested_toml_value(settings, key)+ && should_warn_ignored_global_only_value(&value)+ {+ warn!(+ "{key} in non-global config {} is ignored for security reasons",+ path.display()+ );+ }+ }+}++fn remove_nested_toml_value(table: &mut toml::Table, key: &str) -> Option<toml::Value> {+ let mut parts = key.split('.').collect_vec();+ let last = parts.pop()?;+ let mut current = table;+ for part in parts {+ current = current.get_mut(part)?.as_table_mut()?;+ }+ current.remove(last)+}++fn should_warn_ignored_global_only_value(value: &toml::Value) -> bool {+ !matches!(value, toml::Value::String(s) if s.is_empty())+}+ impl Settings { pub fn parse_default_package_line(package: &str) -> Option<String> { let package = package.split('#').next().unwrap_or_default().trim();@@ -532,7 +567,11 @@ impl Settings { pub fn parse_settings_file(path: &Path) -> Result<SettingsPartial> { let raw = file::read_to_string(path)?;- let settings_file: SettingsFile = toml::from_str(&raw)?;+ let mut raw: toml::Value = toml::from_str(&raw)?;+ if let Some(settings) = raw.get_mut("settings").and_then(toml::Value::as_table_mut) {+ strip_local_only_settings(settings, path, crate::config::is_global_config(path));+ }+ let settings_file: SettingsFile = raw.try_into()?; Ok(normalize_hidden_config_aliases(settings_file.settings)) }@@ -986,6 +1025,79 @@ where mod tests { use super::*; + fn credential_command_settings_table() -> toml::Table {+ toml::from_str::<toml::Value>(+ r#"+ [github]+ credential_command = "echo github-token"++ [gitlab]+ credential_command = "echo gitlab-token"++ [forgejo]+ credential_command = "echo forgejo-token"+ "#,+ )+ .unwrap()+ .as_table()+ .unwrap()+ .clone()+ }++ fn settings_partial_from_table(settings: toml::Table) -> SettingsPartial {+ let mut root = toml::Table::new();+ root.insert("settings".to_string(), toml::Value::Table(settings));+ let settings_file: SettingsFile = toml::Value::Table(root).try_into().unwrap();+ settings_file.settings@@ -673,6 +673,12 @@ pub static SETTINGS_META: Lazy<IndexMap<&'static str, SettingsMeta>> = Lazy::new Some(v) => lines.push(format!(" deprecated_remove_at: Some({v:?}),")), None => lines.push(" deprecated_remove_at: None,".to_string()), }+ lines.push(format!(+ " global_only: {},",+ props+ .get("global_only")+ .is_some_and(|v| v.as_bool().unwrap())+ )); }; for (name, props) in &settings { let props = props.as_table().unwrap();@@ -705,6 +705,7 @@ is trimmed. Example: `op read "op://Private/Forgejo Token/credential"` """ env = "MISE_FORGEJO_CREDENTIAL_COMMAND"+global_only = true type = "String" [forgejo.fj_cli_tokens]@@ -757,6 +758,7 @@ is trimmed. Example: `op read "op://Private/GitHub Token/credential"` """ env = "MISE_GITHUB_CREDENTIAL_COMMAND"+global_only = true type = "String" [github.gh_cli_tokens]@@ -920,6 +922,7 @@ is trimmed. Example: `op read "op://Private/GitLab Token/credential"` """ env = "MISE_GITLAB_CREDENTIAL_COMMAND"+global_only = true type = "String" [gitlab.glab_cli_tokens]AI-assisted change 206e91d251ee65792f9a98447cbf03d67cb8ec18e0ea08eecd4f4bc3c37dfd1d · Fix 1a295ca5475f10a941521dd8e55cfb77f070d60d77426534dfb4263492d640b9