fix(extensions): stop env-var config leaking across prefix-colliding extension IDs#3497
Merged
mnriem merged 3 commits intoJul 14, 2026
Merged
Conversation
…IDs (github#3494) Because ``_`` doubles as both the separator between an extension ID and its config path AND the substitute for ``-`` inside an extension ID, an env var like ``SPECKIT_GIT_HOOKS_URL`` starts with *both* the ``SPECKIT_GIT_`` prefix of the ``git`` extension and the ``SPECKIT_GIT_HOOKS_`` prefix of a co-installed ``git-hooks`` extension. ``ConfigManager._get_env_config`` matched only on the shorter prefix, so the same env var silently surfaced inside both extensions' configs (as ``{'hooks': {'url': ...}}`` for ``git`` and ``{'url': ...}`` for ``git-hooks``). Impact: config intended for one extension leaked into another and, worse, could flip ``config.<field> is set`` hook conditions on the wrong extension. Route the env var to the extension whose normalized ID is the longest match — the more specific one. When another installed sibling's normalized ID + ``_`` claims the remainder, skip the var here. The sibling scan reads ``.specify/extensions/`` directly and degrades to a no-op if the dir is missing (fresh project / ad-hoc harness), so the pre-fix single-extension behaviour is unchanged when there is no collision. Distinct from github#3350 (intra-extension prefix collision between two keys of the same extension) — this fixes the cross-extension case. Fixes github#3494
Contributor
There was a problem hiding this comment.
Pull request overview
Prevents environment-variable configuration from leaking between prefix-colliding extension IDs.
Changes:
- Filters variables owned by longer sibling extension IDs.
- Adds regression and fallback coverage.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
src/specify_cli/extensions/__init__.py |
Adds sibling-aware environment-variable routing. |
tests/test_extensions.py |
Tests collisions, boundaries, and fallback behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Collaborator
|
Please address Copilot feedback |
Address Copilot review on github#3497: ``ExtensionManager.remove(..., keep_config=True)`` preserves the extension directory but drops the registry entry, so the previous directory-scan approach would treat a config-only leftover as an installed sibling and silently discard ``SPECKIT_<sibling>_*`` env vars into no owner. Sourced the sibling list from ``ExtensionRegistry.keys()`` — the registry is the source of truth for "installed" — and kept the same graceful ``[]`` fallback so the fresh-project / ad-hoc harness path is unaffected. Updated the ``TestConfigManagerCrossExtensionEnvLeak`` ``_install`` helper to register its fake installations and added ``test_config_only_leftover_not_treated_as_sibling`` to lock in the new behaviour for the ``keep_config=True`` scenario. Full suite: 3978 passed, 110 skipped.
Address Copilot follow-up on github#3497: ``ExtensionRegistry._load()`` catches ``JSONDecodeError`` / ``FileNotFoundError`` but not decode failures — a registry file with invalid text encoding would surface a ``UnicodeDecodeError`` out of ``_sibling_extension_ids`` and break every config read instead of degrading to the documented pre-fix behaviour. Extend the fallback in ``_sibling_extension_ids`` to also catch ``UnicodeError`` and add ``test_non_utf8_registry_does_not_crash`` as a regression pin (kept ``_load()`` itself out of scope — that broader hardening belongs in a separate PR since it affects all readers). Full suite: 3979 passed, 110 skipped.
Collaborator
|
Please address Copilot feedback |
Contributor
Author
|
@mnriem addressed comments |
|
An explanation of this PR scanned by a tool I'm working on - Hope that it helps: https://www.lenzon.ai/viewer/cmrjulyib0005m7f49oejcyx5?voice=google-chirp3 |
Collaborator
|
Thank you! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #3494.
What
ConfigManager._get_env_configreadsSPECKIT_<EXT_ID>_...env vars by prefix-match. But because_doubles as both the separator between the extension ID and the config path and the substitute for-inside an extension ID, an env var likeSPECKIT_GIT_HOOKS_URLstarts with both theSPECKIT_GIT_prefix of thegitextension and theSPECKIT_GIT_HOOKS_prefix of a co-installedgit-hooksextension.Repro on
main(0.12.13.dev0):Impact:
config.hooks.url is setfire on the wrong extension,Distinct from #3350, which fixed intra-extension collisions (two keys of the same extension colliding on a prefix). This PR fixes the cross-extension case.
Fix
Route each env var to the extension whose normalized ID is the longest match — the more specific one:
ExtensionRegistry(.specify/extensions/.registry) — the registry is the source of truth for "installed", not the on-disk directory. A bare<sibling>/directory can be a config-only leftover fromExtensionManager.remove(..., keep_config=True), which preserves the dir but drops the registry entry; treating it as installed would silently discardSPECKIT_<leftover>_*env vars into no owner.GIT_HOOKSstarts withGIT_), record the portion of the remainder it claims, including a trailing_boundary (so siblinghookcannot false-positive on keyhooks).Fallbacks in
_sibling_extension_ids:ExtensionRegistry(...).keys()returns empty; behaviour is identical to pre-fix._sibling_extension_idscatches(OSError, UnicodeError)and returns[]so a broken registry degrades to the pre-fix single-extension path rather than aborting every config read (ExtensionRegistry._load()only handlesJSONDecodeError/FileNotFoundError, so a non-UTF-8 file would otherwise surfaceUnicodeDecodeError). Hardening_load()itself is left for a separate PR since it affects every reader.Tests
New class
TestConfigManagerCrossExtensionEnvLeakwith 7 tests:test_sibling_owns_longer_prefix_env— the reproducer above, now correct.test_no_sibling_installed_keeps_legacy_absorption— no regression when only one extension is installed.test_non_prefix_sibling_ignored— siblingnot-gitdoes not affectgit.test_boundary_prevents_false_positive— siblinggit-hookdoes not eatHOOKS_*keys.test_missing_extensions_dir_does_not_crash— sibling scan degrades cleanly when.specify/extensions/is absent (registry lookup returns empty).test_config_only_leftover_not_treated_as_sibling— agit-hooks/directory left behind byremove(..., keep_config=True)(dir + config preserved, no registry entry) is not treated as installed;gitstill absorbsSPECKIT_GIT_HOOKS_*.test_non_utf8_registry_does_not_crash— a registry file containing invalid UTF-8 does not propagateUnicodeDecodeErrorout of the sibling scan.The test helper
_installnow writes both the directory and anExtensionRegistry.add(...)entry, matching the registry-as-source-of-truth model.Adjacent
TestConfigManagerEnvPrefixCollision(from #3350) still passes.Full suite:
Manual test results
Agent: N/A — this is a config-loader fix; no slash command's behaviour changes. | OS/Shell: macOS 15 / zsh
src/specify_cli/extensions/__init__.py(ConfigManager._get_env_config+ a new private helper_sibling_extension_ids) is modified. Notemplates/commands/*, noscripts/bash|powershell/*, nosrc/specify_cli/*CLI entry point, no extension.yml/manifest surface.AI disclosure
Per
CONTRIBUTING.md#ai-contributions-in-spec-kit: this PR (bug identification, patch, and tests) was prepared with Claude (Anthropic) assistance in an autonomous agent session. Reproducer was executed against a fresh clone ofmainbefore and after the change; the full test suite (3,979 tests) was run green before submission. The fix is deliberately narrow — one new private helper (_sibling_extension_ids), one added guard in_get_env_config, and seven focused regression tests. The design intentionally preserves the pre-fix single-extension behaviour when no colliding sibling is installed, so the change is a pure additive-safety fix rather than a policy shift.🤖 Generated with Claude Code