Environment
- ggshield version: 1.51.0
- Operating system (Linux, macOS, Windows): macOS (Apple Silicon)
- Operating system version: Darwin 25.5.0
- Python version: 3.14 (bundled with ggshield, installed via mise as part of a toolchain)
Describe the bug
When ggshield is installed via a tool version manager like mise (or asdf, pyenv, pipx, etc.), the binary path can change between installs or reshims. On macOS, Keychain entries have an ACL tied to the creating application path. If the path changes, the new binary gets error -25244 (errSecInvalidOwnerEdit) when trying to overwrite the existing entry.
The result is that:
is_available() returns True (the probe with __ggshield_probe__ always writes fine as a fresh entry)
store_token() silently fails on the real instance URL key due to the ACL mismatch
- The token stays in cleartext in
auth_config.yaml with no visible feedback to the user
This is not specific to Homebrew upgrades — it affects any installation method where the binary path is managed externally (mise, asdf, pipx, Docker volumes, CI runners, etc.).
Steps to reproduce:
- Install ggshield via mise and authenticate (
ggshield auth login)
- Reinstall or reshim ggshield via mise (e.g.
mise install, toolchain update)
- Run any ggshield command
- Check
~/.config/ggshield/auth_config.yaml
Actual result:
The token is still in cleartext in auth_config.yaml, and no warning or error is shown anywhere.
Expected result:
The token is migrated to the Keychain, or — when that is impossible — the user is told the token stays in cleartext and how to fix it.
Root cause
Two issues compound each other:
-
is_available() probes with a dummy key __ggshield_probe__, which always succeeds since it's a fresh entry with no ACL conflict. It never detects that the real instance URL key is locked to a different binary path.
-
_persist_to_keyring() catches the exception but only logs it at logger.warning level, invisible to the end user:
except Exception:
logger.warning(
"Failed to store token in keyring for %s, storing in config file",
inst.url,
exc_info=True,
)
fallback_urls.add(inst.url)
Proposed fixes
- Surface the error visibly with an actionable fix — warn on stderr that the token stays in plaintext, including the commands to recover:
security delete-generic-password -s ggshield -a '<instance url>'
ggshield auth login
-
Detect the conflict where it matters — the dummy-key probe cannot catch per-entry ACL conflicts; the diagnosis should exercise the real instance URL keys.
-
Add a diagnostic command reporting the credential-store backend, whether it is reachable, and where each instance's token actually lives (credential store vs cleartext config file), with a copy-pasteable fix when a token unexpectedly stays in plaintext.
-
Notify the user after migration attempts — confirmation on success, warning on failure.
Environment
Describe the bug
When ggshield is installed via a tool version manager like mise (or asdf, pyenv, pipx, etc.), the binary path can change between installs or reshims. On macOS, Keychain entries have an ACL tied to the creating application path. If the path changes, the new binary gets error
-25244(errSecInvalidOwnerEdit) when trying to overwrite the existing entry.The result is that:
is_available()returnsTrue(the probe with__ggshield_probe__always writes fine as a fresh entry)store_token()silently fails on the real instance URL key due to the ACL mismatchauth_config.yamlwith no visible feedback to the userThis is not specific to Homebrew upgrades — it affects any installation method where the binary path is managed externally (mise, asdf, pipx, Docker volumes, CI runners, etc.).
Steps to reproduce:
ggshield auth login)mise install, toolchain update)~/.config/ggshield/auth_config.yamlActual result:
The token is still in cleartext in
auth_config.yaml, and no warning or error is shown anywhere.Expected result:
The token is migrated to the Keychain, or — when that is impossible — the user is told the token stays in cleartext and how to fix it.
Root cause
Two issues compound each other:
is_available()probes with a dummy key__ggshield_probe__, which always succeeds since it's a fresh entry with no ACL conflict. It never detects that the real instance URL key is locked to a different binary path._persist_to_keyring()catches the exception but only logs it atlogger.warninglevel, invisible to the end user:Proposed fixes
Detect the conflict where it matters — the dummy-key probe cannot catch per-entry ACL conflicts; the diagnosis should exercise the real instance URL keys.
Add a diagnostic command reporting the credential-store backend, whether it is reachable, and where each instance's token actually lives (credential store vs cleartext config file), with a copy-pasteable fix when a token unexpectedly stays in plaintext.
Notify the user after migration attempts — confirmation on success, warning on failure.