Skip to content

fix: harden ExtensionManager.remove and PresetManager.remove against unsafe registry IDs - #4745

Open
chelsealong wants to merge 11 commits into
github:mainfrom
chelsealong:fix/4744-harden-extension-preset-removal
Open

chelsealong wants to merge 11 commits into
github:mainfrom
chelsealong:fix/4744-harden-extension-preset-removal

Conversation

@chelsealong

Copy link
Copy Markdown
Contributor

Summary

Fixes #4744.

ExtensionManager.remove() and PresetManager.remove() used the
registry-supplied extension_id / pack_id directly to build the removal
path (and, for extensions, the .backup/<id> config-backup path) without:

  • validating the id is a single, well-formed path component (the same
    ^[a-z0-9-]+$ slug format already enforced when the id is first written),
    and
  • checking that the resolved target, if it exists, is a real directory
    rather than a symlink.

Since the registry is a plain JSON file, a corrupted or tampered entry
(e.g. an id like ../outside) could point the removal path outside
.specify/extensions / .specify/presets. Separately, a symlink planted at
the extension/preset directory — or at .backup/<extension_id> — could
redirect deletion or config backups to an attacker-chosen location; the
previous code also crashed with an unhandled OSError from
shutil.rmtree on a symlinked top-level target instead of failing
explicitly, as the issue requires.

Both remove() methods now:

  1. Reject an id that doesn't match the existing slug pattern before any
    path is constructed.
  2. Refuse to proceed if the removal target exists but is a symlink or not
    a directory.
  3. (Extensions only) Refuse to proceed if .backup/<extension_id> is a
    symlink, before it is ever created/written into.

All three checks happen before any commands/skills/hooks are unregistered,
so an unsafe id leaves the extension/preset registry and on-disk state
untouched rather than partially removed.

Per the issue's scope, I also audited:

  • Workflow/step removal (src/specify_cli/workflows/command_remove.py)
    already validates the id, stages via rename, and refuses a symlinked
    target — no changes needed there.
  • Integration uninstall (IntegrationManifest.uninstall in
    src/specify_cli/integrations/manifest.py) already rejects paths with
    .. segments, containment-checks via os.path.normpath, and never
    follows/removes symlinks unless force=True — no changes needed there
    either.

Testing

Added regression tests for both managers that reproduce the issue's
reproduction steps (register an unsafe id / plant a symlink, then call
remove()), and confirmed they fail without the fix:

$ git checkout adbd62a -- src/specify_cli/extensions/__init__.py src/specify_cli/presets/__init__.py
$ .venv/bin/python -m pytest tests/test_extensions.py -k "remove_rejects_unsafe or remove_refuses_symlinked" \
                              tests/test_presets.py  -k "remove_rejects_unsafe or remove_refuses_symlinked" -q
...
FAILED tests/test_extensions.py::TestExtensionManager::test_remove_rejects_unsafe_registry_id - assert True is False
FAILED tests/test_extensions.py::TestExtensionManager::test_remove_refuses_symlinked_extension_dir - OSError: Cannot call rmtree on a symbolic link
FAILED tests/test_extensions.py::TestExtensionManager::test_remove_refuses_symlinked_backup_dir - assert True is False
FAILED tests/test_presets.py::TestPresetManager::test_remove_rejects_unsafe_registry_id - assert True is False
FAILED tests/test_presets.py::TestPresetManager::test_remove_refuses_symlinked_preset_dir - OSError: Cannot call rmtree on a symbolic link
5 failed, 1069 deselected in 0.82s

With the fix restored:

$ .venv/bin/python -m pytest tests/test_extensions.py tests/test_presets.py -q
1074 passed, 3 warnings in 29.39s

Full suite (.venv/bin/python -m pytest -q): 8536 passed, 13 skipped, 10 failed.
The 10 failures are all test_*_python_parity.py "composed" template-variant
cases (check_prerequisites, create_new_feature, resolve_template,
setup_plan, setup_tasks), unrelated to extension/preset removal.
Confirmed pre-existing by reverting just this PR's two source files back to
adbd62a (current upstream/main) and re-running: the same failures
reproduce with none of this PR's changes present.

Lint: uvx ruff@0.15.0 check src/specify_cli/extensions/__init__.py src/specify_cli/presets/__init__.py tests/test_extensions.py tests/test_presets.py → All checks passed.

AI Disclosure

Implemented with Claude Code (model: Claude Sonnet 5, autonomous). The
agent read the issue, located the two remove() implementations, audited
the workflow/step and integration-uninstall paths named in the issue for
existing equivalent guards, wrote the fix and the regression tests, and
verified them (including the fail-without-fix check above) without human
line-by-line review prior to this PR being opened.

🤖 Generated with Claude Code

…unsafe registry IDs

A registered extension/preset id from the on-disk registry JSON was used
directly to build the removal (and, for extensions, config-backup) path
without validating it is a single well-formed path component, and
without checking that the resolved target is a real directory rather
than a symlink. A crafted or corrupted registry entry (e.g. an id
containing "../") could therefore point removal outside
.specify/extensions or .specify/presets, and a symlinked target dir or
symlinked .backup/<id> could redirect deletion or config backups
elsewhere; the previous code also crashed with an unhandled OSError
from shutil.rmtree on a symlinked target rather than failing explicitly.

Both remove() methods now validate the id against the same
[a-z0-9-]+ pattern already enforced at install time, and refuse to
proceed if the removal target (or, for extensions, the backup
destination) exists but is not a plain directory.

Workflow/step removal already carries equivalent guards and integration
uninstall (IntegrationManifest.uninstall) already rejects traversal and
symlinked paths, so neither needed changes; this was verified as part
of the same audit requested by the issue.

Fixes github#4744

Assisted-by: Claude Code (model: Claude Sonnet 5, autonomous)
@chelsealong
chelsealong requested a review from mnriem as a code owner September 25, 2026 13:05
@mnriem mnriem added triage-can-wait Verdict: valid and in-scope but deprioritized; held behind the evidence gate author-awaiting Waiting on author response author-needs-rebase Branch conflicts with main — rebase/resolve before merge labels Sep 25, 2026
@mnriem

mnriem commented Sep 25, 2026

Copy link
Copy Markdown
Collaborator

Please resolve conflicts

…-removal

Resolves conflicts from upstream's preset domain split (PR github#4747): the
ExtensionManager/PresetManager.remove() security fix from b6dc0a6 is
ported from the old monolithic presets/__init__.py into the new
PresetManager.remove() in presets/_manager.py, and its regression
tests are ported into tests/specify_cli/presets/test_manager.py.

Assisted-by: Claude Code (model: Claude Sonnet 5, autonomous)
@chelsealong

Copy link
Copy Markdown
Contributor Author

Posted on behalf of @chelsealong by Claude Code (model: Claude Sonnet 5, autonomous); comment fully AI-drafted.

Resolved the conflicts with main in 79abc46. Upstream split presets/__init__.py into presets/_manager.py + friends (#4747) since this PR was opened, so the merge itself was mechanical, but the security fix (PresetManager.remove() id/symlink validation) had to be ported by hand into the new _manager.py, and its two regression tests into tests/specify_cli/presets/test_manager.py (the old tests/test_presets.py unit tests for PresetManager moved there too).

Verified: reverted just _manager.py's fix and confirmed both regression tests fail the same way as before (assert True is False / OSError: Cannot call rmtree on a symbolic link), restored the fix and confirmed they pass, then ran the full suite (8563 passed, 13 skipped) and ruff check on the touched files (all checks passed). No unrelated failures.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Dangling target links and a symlinked backup parent still bypass the new safeguards.

Review effort: Balanced
Findings: 1 High severity · 3 Medium severity

Open (4)
What changed in this PR

Hardens extension and preset removal against tampered registry IDs and symlink targets.

Changes:

  • Validates persisted IDs before path construction.
  • Rejects unsafe removal and backup targets.
  • Adds regression tests for traversal and symlink scenarios.

Testing assessment relied on the supplied evidence; the suite was not rerun here.

File Description
src/​specify_cli/​extensions/​__init__.py Adds removal safety guards.
src/​specify_cli/​presets/​_manager.py Adds preset target validation.
tests/​test_extensions.py Tests extension removal hardening.
tests/​specify_cli/​presets/​test_manager.py Tests preset removal hardening.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/specify_cli/extensions/__init__.py Outdated
Comment thread src/specify_cli/extensions/__init__.py Outdated
Comment thread src/specify_cli/extensions/__init__.py Outdated
Comment thread src/specify_cli/presets/_manager.py Outdated
Address Copilot review on github#4745: use fullmatch (not match) for the
extension-id slug check so a trailing newline can't slip past the `$`
anchor, check is_symlink() independently of exists() so a dangling
symlink at the extension/preset directory is rejected instead of
silently passed through, and reject a symlinked `.backup` parent
directory before it is ever mkdir'd/written into.
@chelsealong

Copy link
Copy Markdown
Contributor Author

Posted on behalf of @chelsealong by Claude Code (model: Claude Sonnet 5, autonomous); comment fully AI-drafted.

Addressed all 4 Copilot findings in fd86df8:

  • extensions/__init__.py: registry-id check now uses fullmatch instead of match (a trailing newline was previously accepted past the $ anchor).
  • extensions/__init__.py: extension_dir symlink check no longer gated behind exists(), so a dangling symlink is rejected instead of silently passed through.
  • extensions/__init__.py: added a check that the .backup parent directory itself isn't a symlink, before it's ever created/written into.
  • presets/_manager.py: same dangling-symlink fix for pack_dir (its id check already used fullmatch).

Added one regression test per finding (tests/test_extensions.py, tests/specify_cli/presets/test_manager.py); confirmed all 4 fail without the corresponding fix and pass with it. Full suite: 8567 passed, 13 skipped. ruff check on touched files: all checks passed.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Bundle removal ignores refusal results, and non-directory backup paths can still cause partial extension removal.

Review effort: Balanced
Findings: 1 High severity · 1 Low severity

Open (2)
Resolved since last review (4)

Comment on lines +2968 to +2969
if not VALID_EXTENSION_ARTIFACT_NAME_PATTERN.fullmatch(extension_id):
return False
if not PresetResolver._is_safe_registry_id(pack_id):
return False
pack_dir = self.presets_dir / pack_id
if pack_dir.is_symlink() or (pack_dir.exists() and not pack_dir.is_dir()):
_ExtensionKindManager.remove() discarded ExtensionManager.remove()'s
bool result, so a tampered registry id or symlinked target that made
the security guard refuse to act still let remove_bundle() record the
component as uninstalled and drop the bundle's ownership metadata,
leaving the extension on disk. Raise BundlerError when remove()
returns False so the bundle removal fails instead of silently
succeeding.

Also add a regression test covering the previously-untested
regular-file preset target rejection in PresetManager.remove().
@chelsealong

Copy link
Copy Markdown
Contributor Author

Posted on behalf of @chelsealong by Claude Code (model: Claude Sonnet 5, autonomous); comment fully AI-drafted.

Addressed the 2 remaining Copilot findings in 52bacea:

  • bundles/primitives.py: _ExtensionKindManager.remove() now raises BundlerError when ExtensionManager.remove() returns False instead of discarding the result, so a refused removal (unsafe registry id or symlinked target) fails bundle uninstall instead of letting remove_bundle() drop the bundle's ownership metadata while the extension is still on disk.
  • presets/_manager.py: added the missing regression test for the regular-file (non-directory) preset-target rejection; no code change was needed there, the guard already existed.

Confirmed the bundle-removal test fails without the fix (DID NOT RAISE BundlerError) and passes with it restored. Full suite: 8569 passed, 13 skipped. ruff check on touched files: all checks passed.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Preset bundle removal still ignores safety refusals, and unconditional backup checks regress valid extension removals.

Review effort: Balanced
Findings: 2 High severity · 1 Medium severity · 1 Low severity

Open (4)

Comment thread src/specify_cli/bundles/primitives.py
Comment thread src/specify_cli/extensions/__init__.py Outdated
@mnriem

mnriem commented Sep 28, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

_PresetKindManager.remove() discarded PresetManager.remove()'s False
return the same way the extension adapter did before 52bacea, letting
bundle uninstall drop ownership metadata for a preset that was refused
removal. Mirror the extension fix by raising BundlerError.

Also stop failing extension removal when keep_config=True or the
extension directory is already missing: the .backup symlink checks
ran unconditionally even though no backup is written in either case,
regressing valid removals.
@chelsealong

Copy link
Copy Markdown
Contributor Author

Posted on behalf of @chelsealong by Claude Code (model: Claude Sonnet 5, autonomous); comment fully AI-drafted.

Addressed the 2 new Copilot findings in 6c86c61:

  • bundles/primitives.py: _PresetKindManager.remove() now raises BundlerError when PresetManager.remove() returns False, mirroring the extension-side fix from 52bacea (it previously discarded a refused preset removal, letting bundle uninstall drop ownership metadata for a preset still on disk).
  • extensions/__init__.py: the .backup symlink checks now only run if not keep_config and extension_dir.exists() — the same condition under which a backup is actually written — so keep_config=True or an already-missing extension directory no longer trips on an unrelated symlink at .backup.

Added one regression test per finding; confirmed both fail without the corresponding fix and pass with it. Full suite: 8571 passed, 13 skipped. ruff check on touched files: all checks passed.

The other two items Copilot still lists as open (Propagate extension removal refusal... / Test regular-file preset targets...) were already fixed in 52bacea — verified the code and tests are in place, no further change needed there.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Non-directory backup paths can still cause partial extension removal, and one new validation branch lacks regression coverage.

Review effort: Balanced
Findings: 1 High severity · 1 Low severity

Open (2)
Resolved since last review (2)
Previously missed (1)

In code that hasn't changed since last review

Low severity Add regression test for regular-file backup path rejection

src/​specify_cli/​extensions/​__init__.py:2973

Add regression coverage for the new regular-file rejection branch. Preset removal verifies this case in tests/specify_cli/presets/test_manager.py:358-372, but extension removal only tests unsafe IDs and symlinks, so the exists() and not is_dir() behavior can regress unnoticed despite the repository's positive/negative testing requirement.

This issue also appears on line 2977 of the same file.

@mnriem

mnriem commented Sep 28, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

@chelsealong

Copy link
Copy Markdown
Contributor Author

Posted on behalf of @chelsealong by Claude Code (model: Claude Sonnet 5.5, autonomous); comment fully AI-drafted.

Addressed the remaining Copilot finding: ExtensionManager.remove() now also refuses (before unregistering anything) when .backup or .backup/<id> exists as a non-directory, using the same not keep_config and extension_dir.exists() gate. Added test_remove_refuses_regular_file_backup_path; it fails without the change (FileExistsError) and passes with it. tests/test_extensions.py + tests/specify_cli: 3092 passed.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Backup-file symlinks remain exploitable, and existing force/update callers ignore removal refusals.

Review effort: Balanced
Findings: 1 High severity · 1 Low severity

Open (2)
Previously missed (3)

In code that hasn't changed since last review

Medium severity Extension ID validation permits IDs that removal rejects

src/​specify_cli/​extensions/​__init__.py:2969

This removal check is stricter than the current writer-side validation: ExtensionManifest uses re.match(r"^[a-z0-9-]+$", ...) at line 320, which accepts an ID ending in a newline because $ can match before that newline. Such an extension can be installed normally and written to the registry, but this fullmatch then makes it unremovable. Change manifest validation to the same full-match semantics and add an install-to-remove regression case.

This issue also appears on line 2979 of the same file.

Medium severity Install and update callers ignore failed removal

src/​specify_cli/​extensions/​__init__.py:2974

Existing callers do not consistently honor this new refusal. install_from_directory(..., force=True) stores the False result at line 2170 but continues to shutil.rmtree(dest_dir) at line 2540 (after already deleting the backup path), while _command_update_transaction.py:523 discards the result and starts installation/rollback. A symlinked target therefore still produces later raw filesystem errors and can trigger unrelated cleanup. Make both callers abort immediately when removal returns False, and cover the force/update paths with regression tests.

Medium severity Manifest ID validation permits IDs that removal rejects

src/​specify_cli/​presets/​_manager.py:649

PresetManifest still validates IDs with re.match(...$) at _manifest.py:120, so a trailing-newline ID can pass normal installation and be persisted. This stricter registry check then refuses to remove that normally installed preset. Align the manifest validator with this fullmatch rule and add an end-to-end regression test so installation cannot create an ID that removal rejects.

This issue also appears on line 650 of the same file.

@mnriem

mnriem commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

@chelsealong

Copy link
Copy Markdown
Contributor Author

Posted on behalf of @chelsealong by Claude Code (model: Claude Sonnet 5.5, autonomous); comment fully AI-drafted.

Addressed the remaining Copilot findings in 5ed47cc:

  • ExtensionManifest / PresetManifest id validation now uses fullmatch, so install can't persist an id (e.g. trailing newline) that remove() rejects.
  • install_from_directory(force=True) and the extension update transaction now abort when remove() refuses, instead of continuing to rmtree/install.

Added a test for each; all 4 fail without the source changes and pass with them. tests/test_extensions.py + tests/specify_cli: 3096 passed.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Force-reinstall and update paths can still perform unsafe or destructive work after encountering guarded filesystem states.

Review effort: Balanced
Findings: 2 High severity · 1 Medium severity · 1 Low severity

Open (4)
Previously missed (1)

In code that hasn't changed since last review

Medium severity Abort force reinstall when removal refuses symlinked target

src/​specify_cli/​presets/​_manager.py:652

install_from_directory(force=True) still ignores this new False result at line 398 and then calls shutil.rmtree(dest_dir) on the same symlink at line 402. Thus force-reinstalling a preset with a symlinked target still raises the unhandled OSError this hardening is intended to replace. The force path must abort when remove() refuses, as the extension installer now does.

Comment thread src/specify_cli/extensions/__init__.py
Comment thread src/specify_cli/extensions/_command_update_transaction.py
@mnriem

mnriem commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

…nstall on refused removal, keep update refusal non-destructive
@chelsealong

Copy link
Copy Markdown
Contributor Author

Posted on behalf of @chelsealong by Claude Code (model: Claude Sonnet 5.5, autonomous); comment fully AI-drafted.

Addressed the latest Copilot findings in 95d0787:

  • extensions/__init__.py: install_from_directory(force=True) now rejects a symlinked .backup root before the stale-backup cleanup runs.
  • presets/_manager.py: install_from_directory(force=True) now aborts when remove() refuses, instead of proceeding to rmtree.
  • _command_update_transaction.py: installation_modified is reset before raising on a refused removal, so rollback stays non-destructive.

Added a test each for the first two; both fail without the source change and pass with it. No test for the third (the existing update-refusal test passes either way). tests/test_extensions.py + tests/specify_cli: 1265 passed.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

File-level backup symlinks remain exploitable, and preset bundle removal can still bypass refusal.

Review effort: Balanced
Findings: 2 High severity · 1 Low severity

Open (3)
Resolved since last review (2)
Previously missed (2)

In code that hasn't changed since last review

Medium severity remove_bundle bypasses preset refusal for corrupt symlink targets

src/​specify_cli/​bundles/​primitives.py:225

Preset refusal is still bypassed by the real remove_bundle() path. _PresetKindManager.is_installed() calls get_pack(), which returns None when the registered target is an empty/corrupt symlink; remove_bundle() then skips this remove() call and deletes the bundle record while the preset registry entry remains. Use the preset registry as the installed-state source (as the extension adapter does) and cover this through remove_bundle(), rather than only invoking the adapter directly.

Low severity Add extension regression test for regular-file target refusal

src/​specify_cli/​extensions/​__init__.py:2983

The new regular-file refusal branch lacks extension-side regression coverage. The preset suite has a matching regular-file target test, but the extension tests only exercise symlink targets and a regular file at the backup path; add a case asserting that a regular file at .specify/extensions/<id> is preserved, remove() returns False, and the registry remains unchanged.

Comment thread src/specify_cli/extensions/__init__.py
@mnriem

mnriem commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

@chelsealong

Copy link
Copy Markdown
Contributor Author

Posted on behalf of @chelsealong by Claude Code (model: Claude Sonnet 5.5, autonomous); comment fully AI-drafted.

Addressed the open Copilot findings in b2b2f48:

  • extensions/__init__.py: remove() now refuses (before unregistering) when .backup/<id>/<config> is a symlink, so copy2 can't write through it.
  • bundles/primitives.py: _PresetKindManager.is_installed() uses the preset registry, so remove_bundle() reaches remove() (and its refusal) for a corrupt symlinked target.
  • Added an extension regular-file target test (coverage only; the guard already existed).

The backup-file and preset is_installed tests fail without the source change and pass with it. Affected test dirs: 1657 passed; ruff 0.15.0 clean on touched files.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Backup destination directories can still cause configuration loss, and required missing-target regressions are absent.

Review effort: Balanced
Findings: 2 High severity · 1 Low severity

Open (3)
Resolved since last review (1)
Previously missed (2)

In code that hasn't changed since last review

Low severity Clarify refusal message for unexpected preset removal targets

src/​specify_cli/​bundles/​primitives.py:234

The refusal reason is incomplete: PresetManager.remove() also returns False when the target is a regular file, so this message incorrectly tells users that only an unsafe ID or symlink can be responsible. Describe this as an unsafe registry ID or unexpected on-disk target so the regular-file case is actionable.

This issue also appears on line 327 of the same file.

Low severity Test removing registered presets with missing install directories

src/​specify_cli/​presets/​_manager.py:656

Add the required positive regression for a valid registered preset whose install directory is already missing. The new guard intentionally permits that state, but the added tests only cover an absent registry entry and rejected filesystem objects; they do not verify that remove() still returns True and clears the registry when the registered target is missing, as required by issue #4744.

Comment thread src/specify_cli/extensions/__init__.py
@chelsealong

Copy link
Copy Markdown
Contributor Author

Posted on behalf of @chelsealong by Claude Code (model: Claude Sonnet 5.5, autonomous); comment fully AI-drafted.

Addressed the open high-severity finding: ExtensionManager.remove() now also refuses (before unregistering) when .backup/<id>/<config> exists as a directory, not just a symlink. Added test_remove_refuses_directory_backup_config_path; it fails without the source change and passes with it. tests/test_extensions.py + tests/specify_cli: 3102 passed; ruff 0.15.0 clean.

Not changed in this push: the two low-severity items (preset refusal message wording, missing-target preset positive test).

@chelsealong

Copy link
Copy Markdown
Contributor Author

Posted on behalf of @chelsealong by Claude Code (model: Claude Sonnet 5.5, autonomous); comment fully AI-drafted.

Addressed the two remaining low-severity items: the bundle refusal messages in bundles/primitives.py now say "unsafe registry id or unexpected on-disk target", and I added test_remove_registered_preset_with_missing_dir (remove() returns True and clears the registry when the target is already gone). Test-only plus message change. tests/specify_cli/presets/test_manager.py + tests/specify_cli/bundles: 479 passed.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

The safety checks are applied before destructive manager operations and are supported by focused regression coverage.

Review effort: Balanced
Findings: 1 High severity · 1 Low severity

Open (2)
Resolved since last review (1)

@mnriem

mnriem commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

author-awaiting Waiting on author response author-needs-rebase Branch conflicts with main — rebase/resolve before merge triage-can-wait Verdict: valid and in-scope but deprioritized; held behind the evidence gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Harden filesystem removal of installed components

3 participants