Skip to content

Plugin bundles should embed apm.lock.yaml (supply-chain integrity + restores apm pack/unpack round-trip) #1098

@danielmeppiel

Description

@danielmeppiel

Updated after devx-ux review. Original framing (just "ship the lockfile, extend unpack mapping") was too narrow. The cleaner unification: apm unpack shouldn't exist as a separate verb. Plugin bundles are pre-resolved packages; apm install already knows how to integrate those into a project. This issue now proposes that direction. Original problem statement preserved below for context.

TL;DR

  1. Supply-chain: plugin bundles ship no apm.lock.yaml, so they have no embedded SBOM / integrity manifest / provenance. Add it.
  2. Mental model: a plugin bundle IS a pre-resolved package. apm install already knows how to route a package's flat skills//agents//commands//instructions/ layout to the correct target tree. Make apm install accept a local plugin bundle (file or directory). This is what pip install ./foo.whl, cargo install --path ., and npm install ./pkg.tgz already do.
  3. Deprecate apm unpack (and the apm bundle format) over two release cycles. Single canonical bundle format = plugin. Single canonical entry point = install.
  4. Round-trip restored as a free side effect.

Background (verified empirically against apm HEAD 0.11.0, commit 2b9501ab)

  • apm pack default flipped from apm to plugin format in 0.11.
  • plugin bundle: <root>/plugin.json + flat skills/<slug>/SKILL.md, agents/<name>.md, commands/<name>.md, instructions/<file>. No apm.lock.yaml -- explicitly excluded at src/apm_cli/bundle/plugin_exporter.py:140-155.
  • apm bundle: <root>/.github/<kind>/<slug>/<file> (target-deployed paths) + apm.lock.yaml whose deployed_files drives apm unpack.
  • apm unpack <plugin.tar.gz> exits 1 with apm.lock.yaml not found. Default pack -> unpack round-trip is broken in 0.11+ unless the user passes --format apm.
  • microsoft/apm-action PR Refactor prompt integration and add agent integration #31 currently defends downstream by defaulting bundle-format: apm and rejecting plugin tarballs at restore.

Why "ship the lockfile" alone isn't the right framing

LockedDependency (src/apm_cli/deps/lockfile.py:20-50) records repo_url, resolved_commit, resolved_ref, version, deployed_files, deployed_file_hashes, content_hash, is_insecure, discovered_via. It is already an SBOM + integrity manifest. Excluding it from plugin bundles means:

  • No tamper evidence -- a plugin bundle modified post-pack is indistinguishable from the legitimate one.
  • Not reproducible -- third parties cannot re-resolve from sources and verify they get the same artifact.
  • No machine-readable dep graph for scanners -- CVE/license/policy tooling cannot operate on the artifact.
  • Inconsistent with project posture -- APM enforces lockfile-frozen installs, hash verification, and content-security scanning everywhere else.

So the lockfile must be in plugin bundles for supply-chain reasons, full stop. (Precedent: pip RECORD, cargo shipping Cargo.lock in source crates, OCI manifests with layer digests, SLSA provenance, CycloneDX/SPDX SBOMs.)

But shipping the lockfile is not the way to fix unpack, because that path keeps two parallel verbs (install + unpack), two parallel bundle formats (apm + plugin), and two parallel routing-mapping codepaths. The integration pipeline that apm install already runs is exactly the logic unpack should be calling.

Proposed direction: unify on apm install

A plugin bundle is a pre-resolved package with an integration-ready flat layout. The existing apm install pipeline already:

  • Routes skills/, agents/, commands/, instructions/ to the correct target tree (.github/, .claude/, .gemini/, .cursor/, ...).
  • Applies the canonical naming convention.
  • Honors --target <t1,t2,...> for multi-target deploys.
  • Detects collisions via BaseIntegrator.check_collision() (skips locally-modified files unless --force).
  • Supports --dry-run.

All of this should "just work" if apm install accepts a local bundle:

apm install ./bundle.tar.gz             # detect plugin.json -> treat as pre-resolved
apm install ./bundle-dir/               # same, directory form
apm install owner/repo                  # remote, full resolution (today's behavior)

This matches what pip/cargo/npm already do, eliminating an APM-specific verb users have to learn.

Concrete behavior

  1. Detection: apm install <local-path> checks for plugin.json at root. If present, treat the path as a pre-resolved package and skip dependency resolution.
  2. Lockfile: read embedded apm.lock.yaml (after change 1 below). Use it for provenance verification (hash check on each file, integrity check on content_hash) -- NOT for routing. This is a semantic inversion of the lockfile's role inside a bundle (today: routing manifest; tomorrow: SBOM/integrity).
  3. Routing: hand off to the same integrator pipeline apm install owner/repo already runs. --target resolves identically (auto-detect from apm.yml or workspace; explicit --target a,b for multi-target).
  4. Naming: derive the package identifier for the -<package> suffix from plugin.json:id. Fallback: bundle dirname. Error if neither resolves. Add --as <alias> to override (matching the existing owner/repo@alias pattern).
  5. Collisions: inherit install's collision behavior (skip locally-modified files unless --force). Print skip count + remediation:
    [!] 3 files skipped (locally modified). Use --force to overwrite.
        .github/instructions/coding-style.md
        .claude/agents/reviewer.md
        .cursor/rules/linting.md
    
  6. Offline guarantee: apm install <local-path> must do zero network I/O when the source is a local file. Verify the integrator codepath has no implicit network calls (link resolution, marketplace lookups). The current apm unpack is fully offline; that property must be preserved when migrating bundles to install.

Coordinated changes required

Change 1 -- ship apm.lock.yaml in plugin bundles. Drop it from the exclusion list in _collect_root_plugin_components and per-dependency walker (plugin_exporter.py:140-155). Place at <bundle-root>/apm.lock.yaml. Claude Code consumers ignore it. Bundle gains a verifiable SBOM at no behavioral cost.

Change 2 -- apm install accepts local bundle paths. Detect plugin.json at root, route through the existing integrator pipeline, verify against embedded lockfile.

Change 3 -- embed pack-time target metadata for round-trip fidelity. A bundle packed with --target copilot,claude and re-installed in a project that auto-detects only copilot would silently drop the claude artifacts. Embed the original --target set in plugin.json (or apm.lock.yaml) and have install warn:

[!] Bundle was packed for [copilot, claude]. Current resolved target is [copilot].
    Use --target copilot,claude to restore the full layout.

Change 4 -- deprecate apm unpack and the apm bundle format. Suggested timeline:

Release apm install <bundle> apm unpack <bundle> --format apm
v0.12 works on plugin bundles via integrator thin alias -> install; deprecation notice works; no notice
v0.13 unchanged deprecation notice deprecation notice
v0.14 or v1.0 unchanged removed removed

Two release cycles is reasonable for a pre-1.0 tool.

Footguns / blind spots called out

  • Lockfile role inversion (today: routing; tomorrow: provenance) needs explicit documentation so reviewers and downstream tools don't assume routing semantics.
  • Air-gapped installs must remain network-free; integrator codepath needs an audit before unifying.
  • Round-trip fidelity requires embedding target-set metadata in the bundle (Change 3).
  • Behavior migration for users of today's apm unpack: file layout was bundle-baked; with the new path it becomes target-resolved. Migration notes should call this out explicitly.

Downstream impact

  • microsoft/apm-action PR Refactor prompt integration and add agent integration #31: defensive bundle-format: apm default and plugin-rejection error stay useful as defense-in-depth until v0.13. After v0.14, action can drop both and let bundle-format follow upstream default.
  • gh-aw workflows that round-trip via apm pack + apm unpack are silently broken on 0.11+ today; fixed once Change 2 lands.
  • CI integrity gates: workflows can verify bundle provenance from the embedded lockfile. Capability does not exist today on plugin bundles.

Suggested labels

type/feature, theme/security, theme/portability, area/package-authoring, status/needs-triage

References

  • Plugin exporter exclusion list: src/apm_cli/bundle/plugin_exporter.py:140-155
  • LockedDependency schema: src/apm_cli/deps/lockfile.py:20-50
  • Empirical reproduction + downstream defense: microsoft/apm-action#31
  • Related downstream issue: microsoft/apm-action#24 (setup-only) -- separate but driven by the same hardening pass.

Original framing (preserved for history)

Original draft proposed extending the lockfile schema (deployed_files: list[{bundle, deployed}]) so apm unpack could route plugin bundles via lockfile mapping. devx-ux review pushed back: that keeps two parallel verbs and two parallel formats. The unified-on-install direction above is strictly cleaner, matches package-manager precedent (pip/cargo/npm), and the schema extension is unnecessary because the integration pipeline already knows how to route flat plugin layouts.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/lockfileLockfile schema, per-file provenance, integrity hashes, drift detection.area/package-authoringapm pack/unpack, plugin authoring, vendoring guidance, bundle format.status/needs-designDirection approved, design discussion required before code.status/triagedInitial agentic triage complete; pending maintainer ratification (silence = approval).theme/portabilityOne manifest, every target. Multi-target deploy, marketplace, packaging, install.theme/securitySecure by default. Content scanning, lockfile integrity, MCP trust boundaries.type/featureNew capability, new flag, new primitive.

    Type

    No type

    Projects

    Status

    Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions