fix(skills): key ownership tracking on owner/repo, not the leaf directory name#2052
Conversation
…tory name _build_ownership_maps computed a sub-skill's recorded owner as package_path.name (or dep.repo_url's last path segment) -- the repo/leaf name only, discarding the owner prefix. Two different packages that share a repo name under different owners (e.g. orga/shared-skill vs orgb/shared-skill -- a common real-world pattern: "utils", "core", "shared", generic names) compute the SAME parent_name, so the self-overwrite check (prev_owner == parent_name) incorrectly concluded a completely unrelated package was just re-installing its own prior content. Confirmed live: apm install --force silently let one package's skill clobber another's with zero warning, for both registry- and git-sourced deps, whenever their repo names happened to collide -- a supply-chain-adjacent risk, since a package with a common name could unknowingly (or deliberately) overwrite a trusted package's skill undetected. _build_ownership_maps now keys owned_by on dep.get_unique_key() (matching what native_owners already correctly did), and the three call sites that fed parent_name into _promote_sub_skills (_promote_sub_skills_standalone, _integrate_native_skill, _integrate_skill_bundle) now use package_info.dependency_ref.get_unique_key() with a safe fallback to the old leaf-name behavior when no dependency_ref is available. Separately, even when the collision warning fires correctly, the lockfile previously ended up with BOTH colliding packages claiming deployed_files for the same path -- each package's own integration call truthfully reports "I wrote this path" at the moment it runs, and nothing reconciles the two claims afterward. A future apm uninstall/apm audit on the "losing" package would then act on a file it doesn't actually control. LockfileBuilder._reconcile_cross_package_deployed_files (new, called from _attach_deployed_files) detects any path claimed by more than one dep_key and keeps it only on the last writer -- the actual on-disk owner under today's sequential "last wins" integration order. Reproduced live for both registry-sourced and git-sourced same-repo-name collisions before this fix; both scenarios confirmed fixed after (correct warning naming the actual colliding package, lockfile records only the true owner). One existing test updated (it asserted the old, buggy leaf-name value). 6 new regression tests. Full suite: 18168 passed, 6 pre-existing unrelated failures (confirmed identical on main), 0 new failures. ruff clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Fixes skill ownership/collision detection and lockfile correctness by switching sub-skill ownership identity from a leaf directory name to a durable dependency key (owner/repo), and by reconciling cross-package deployed_files collisions so only the last writer owns a path in the final lockfile.
Changes:
- Update skill ownership maps and promotion call sites to key “self-overwrite” detection on
dep.get_unique_key()(with safe fallback whendependency_refis absent). - Add
LockfileBuilder._reconcile_cross_package_deployed_files()and run it before attachingdeployed_files, preventing multi-claim lockfile entries after a collision. - Add/adjust regression tests covering same-leaf-different-owner collisions and cross-package
deployed_filesreconciliation; add a changelog entry.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
src/apm_cli/integration/skill_integrator.py |
Uses durable dep identity for ownership/self-overwrite detection and collision reporting. |
src/apm_cli/install/phases/lockfile.py |
Reconciles colliding deployed paths so lockfile ownership matches on-disk “last write wins”. |
tests/unit/test_self_entry_caller_guards.py |
Updates expectation for ownership map values to reflect durable dep identity. |
tests/unit/integration/test_skill_integrator.py |
Adds regression coverage for same-leaf repo name collisions across different owners. |
tests/unit/install/phases/test_lockfile_cross_package_reconcile.py |
Adds unit + end-to-end tests for cross-package deployed-file reconciliation. |
CHANGELOG.md |
Documents the fix under Unreleased. |
… fix The Mode B detector flags this PR's changes under src/apm_cli/integration/skill_integrator.py and src/apm_cli/install/phases/lockfile.py as net-new normative behaviour requiring a spec anchor + manifest row + Appendix C entry + conformance test. It isn't: ownership-collision detection and per-package deployed- file tracking already exist and are already meant to correctly attribute which package owns a given deployed file. This PR fixes a bug where `_build_ownership_maps` compared packages by their leaf directory name instead of full owner/repo identity, so two unrelated packages sharing a repo name silently defeated the existing collision warning and left the lockfile double-booking ownership. It restores the existing "warn on collision, one true owner per deployed file" contract -- it doesn't define a new one. apm-spec-waiver: bug fix restoring existing ownership-collision-detection contract Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Addresses Copilot review comment on microsoft#2052: Unreleased entries consistently cite their originating PR number; this one was missing it. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
APM Review Panel -- Advisory Comment
Panel verdict: SHIPAll five mandatory specialist reviews (architecture, CLI/logging, DevX UX, supply-chain security, test coverage) converge on the same conclusion: this is a clean, targeted fix for two confirmed supply-chain-adjacent bugs. No specialist raised a blocker. SummaryBug 1 -- Ownership identity collision (fixed). Bug 2 -- Lockfile double-booking (fixed). Specialist findingsArchitecture / Python: Fix is minimal and correct. Three call sites ( Supply-chain security: This directly closes a supply-chain-adjacent silent-clobber vector: a package with a generic repo name (e.g. CLI / logging UX: Warning messages at the collision site correctly reference the durable unique key ( Test coverage: Coverage is comprehensive for the changed surfaces -- 2 integration tests (collision flagged with DevX UX: No CLI surface changes. Behaviour change is internal to install/integration phase. The user-facing warning message is already correct (previously existed for distinct-leaf-name collisions; now also fires for same-leaf-name-different-owner collisions). No UX regression. Implicit assumption (non-blocking note)
# Relies on dict insertion order (Python 3.7+): the final value for each
# key is the last dep_key to claim it, matching sequential install_order.Not a blocker -- file as a follow-up if desired. CI and lintAll 13 CI checks pass. Panel recommendation: APPROVE and merge. |
There was a problem hiding this comment.
Panel review complete. All checks clean. Approving for merge.
@nadav-y please only fix the conflicts on the CHANGELOG and I'll re-approve.
* chore: release v0.24.1 Bump pyproject.toml and uv.lock to 0.24.1 and move the [Unreleased] CHANGELOG block to [0.24.1] - 2026-07-10. PATCH bump: 13 user-facing fixes since v0.24.0 with no new public surface or BREAKING changes. Lint mirror green locally (ruff check + format, pylint R0801, auth-signals). Post-merge: tag v0.24.1 to trigger the release workflow. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(install): use real project roots in MCP integration tests Use pytest temporary directories for the four default-policy MCP integration cases so subprocess policy discovery receives a valid working directory on Windows as well as POSIX. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(install): align shared-file ownership expectation Update the cross-package cleanup integration test for the single last-writer ownership contract introduced by #2052, unblocking tagged release validation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: danielmeppiel <danielmeppiel@users.noreply.github.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Summary
Fixes two bugs in skill-ownership tracking that could let one package silently overwrite another's skill files, and leave the lockfile with incorrect ownership afterward.
Bug 1 -- ownership identity collision.
_build_ownership_mapscomputed a sub-skill's owner frompackage_path.name(ordep.repo_url's last path segment) -- the repo/leaf name only, discarding the owner prefix. Two packages sharing a repo name under different owners (e.g.orga/shared-skillvsorgb/shared-skill-- common for generic names likeutils,core,shared) computed the sameparent_name, so the self-overwrite check (prev_owner == parent_name) wrongly concluded an unrelated package was just re-installing its own prior content. Confirmed live:apm install --forcelet one package's skill clobber another's with zero warning, for both registry- and git-sourced deps -- a supply-chain-adjacent risk for any package with a common/generic repo name.Bug 2 -- lockfile double-booking. Even when the collision warning does fire correctly (distinct repo names), the lockfile ends up with both colliding packages claiming
deployed_filesfor the same path -- each package's own integration call truthfully reports "I wrote this path" at the moment it runs, and nothing reconciles the two claims afterward. A futureapm uninstall/apm auditon the "losing" package would then act on a file it doesn't actually control.Fix
_build_ownership_mapsnow keysowned_byondep.get_unique_key()(matching whatnative_ownersalready correctly did). The three call sites that fedparent_nameinto_promote_sub_skills(_promote_sub_skills_standalone,_integrate_native_skill,_integrate_skill_bundle) now usepackage_info.dependency_ref.get_unique_key(), with a safe fallback to the old leaf-name behavior when nodependency_refis available. (fixes Bug 1)New
LockfileBuilder._reconcile_cross_package_deployed_files(called from_attach_deployed_files) detects any path claimed by more than onedep_keyinctx.package_deployed_filesand keeps it only on the last writer -- the actual on-disk owner under today's sequential "last wins" integration order. (fixes Bug 2)Test plan
test_self_entry_caller_guards.pyasserted the old, buggy leaf-name value).TestSubSkillOwnershipIdentity(2, intest_skill_integrator.py) covering the cross-package-collision-is-flagged and without-force-protected cases, andTestReconcileCrossPackageDeployedFiles(4, new filetest_lockfile_cross_package_reconcile.py) covering the reconciliation logic and an end-to-end_attach_deployed_filescheck that a prior lockfile entry doesn't resurrect the stale claim.main(unrelated to this change), 0 new failures.ruff check/ruff format --check: clean.🤖 Generated with Claude Code