fix(extensions): stop env-var config leaking across prefix-colliding extension IDs#3497
fix(extensions): stop env-var config leaking across prefix-colliding extension IDs#3497thejesh23 wants to merge 3 commits into
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
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.
|
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.
| Sourced from ``ExtensionRegistry`` (``.specify/extensions/.registry``) | ||
| rather than a directory scan: ``ExtensionManager.remove(..., | ||
| keep_config=True)`` deliberately preserves the extension directory | ||
| while dropping the registry entry, so a directory scan would treat | ||
| that config-only leftover as an installed sibling and keep silently | ||
| absorbing its ``SPECKIT_<sibling>_*`` env vars into no one. The | ||
| registry is the source of truth for "installed". |
There was a problem hiding this comment.
Good catch — updated the Fix and Tests sections of the PR description to reflect the registry-sourced sibling scan (ExtensionRegistry.keys() instead of the directory scan) and the current seven-test list, including test_config_only_leftover_not_treated_as_sibling (keep_config=True leftover) and test_non_utf8_registry_does_not_crash (corrupted-encoding fallback). The Fix section also now names the (OSError, UnicodeError) fallback in _sibling_extension_ids and its rationale. No code changes in this update — description-only.
|
Please address Copilot feedback |
|
@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 |
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