Skip to content

feat(install): experimental Copilot canvas extensions#1689

Open
sergio-sisternes-epam wants to merge 10 commits into
mainfrom
sergio-sisternes-epam/experimental-canvas-extensions
Open

feat(install): experimental Copilot canvas extensions#1689
sergio-sisternes-epam wants to merge 10 commits into
mainfrom
sergio-sisternes-epam/experimental-canvas-extensions

Conversation

@sergio-sisternes-epam

@sergio-sisternes-epam sergio-sisternes-epam commented Jun 7, 2026

Copy link
Copy Markdown
Collaborator

feat(install): experimental Copilot canvas extensions

TL;DR

A GitHub Copilot CLI canvas (a directory with an executable extension.mjs, produced by the create-canvas skill) had no way to ship through APM — there was no primitive, no integrator, and no target mapping carrying it from a package into a consumer's .github/extensions/. This PR adds an experimental, Copilot-only canvas primitive so a package can declare .apm/extensions/<name>/ and have apm install / apm pack deploy it verbatim to .github/extensions/<name>/, where Copilot CLI discovers it in-session. The surface is protected by two independent gates — an experimental feature flag and a dependency-trust opt-in for executable code — and is fail-closed on every path that materialises canvas files. It also supports apm install --global, deploying a dependency-provided canvas to ~/.copilot/extensions/<name>/ so it is available in every Copilot session (user scope always requires --trust-canvas-extensions).

Note

Experimental and opt-in twice over: nothing deploys unless the operator runs apm experimental enable canvas, and a dependency's canvas additionally requires --trust-canvas-extensions. No existing install behaviour changes when the flag is off.

Problem (WHY)

  • The create-canvas skill emits a Copilot CLI extension, but APM has no canvas/extension primitive — instructions, prompts, agents, skills, hooks, commands, plugins, and MCP are the only known types. There is nowhere for a canvas to live or be routed.
  • Copilot CLI only discovers extensions in immediate subdirectories of .github/extensions/<name>/ (project) and ~/.copilot/extensions/<name>/ (user). Without an integrator that targets those paths, a packaged canvas is never available in the session.
  • [!] A canvas extension.mjs is arbitrary executable Node code. Shipping one transitively via a dependency is a code-execution surface that must not deploy silently.

Why these matter: an experimental flag alone is a feature-availability switch, not a security control — the experimental subsystem states flags "MUST NOT gate security-critical behaviour". Executable-code trust therefore needs its own gate, mirroring the existing --trust-transitive-mcp boundary.

Approach (WHAT)

# Change
1 New canvas experimental flag (feature availability; default off).
2 New --trust-canvas-extensions install flag plumbed end-to-end (request → context → integrate phase), mirroring trust_transitive_mcp.
3 New CanvasIntegrator (Copilot-only) with atomic per-bundle deploy, strict name validation, and containment checks.
4 Target mapping + dispatch entry so canvas routes only on the copilot target. Project scope deploys to .github/extensions/; --global deploys a dependency-provided canvas to ~/.copilot/extensions/. Non-Copilot targets stay hard-unsupported.
5 Fail-closed enforcement of both gates on the offline apm install <bundle> and apm unpack paths, so a vendored bundle cannot smuggle executable canvas code.
6 First-party status decided by the call path, never inferred from a package-name string a dependency could spoof.

Implementation (HOW)

  • src/apm_cli/integration/canvas_integrator.py (new) — CanvasIntegrator scans .apm/extensions/*/extension.mjs, validates each name, applies the trust gate for dependency sources, and deploys each bundle atomically (plan → directory-collision guard → unmanaged-collision skip → adopt-if-byte-identical → copy). At user scope it deploys only dependency-provided canvases, blocks first-party root bundles, and refuses a non-default $COPILOT_HOME. sync_for_target is deliberately not flag-gated so uninstall always removes a previously-deployed canvas (project or user scope). Also exports is_canvas_bundle_path shared with the offline paths.
  • src/apm_cli/integration/targets.py — canvas is no longer in the copilot unsupported_user_primitives set, so for_scope(user_scope=True) keeps the mapping with root_dir=".copilot" and resolved_deploy_root=None, yielding a clean home-relative lockfile path (no cowork:// scheme) that the user-scope uninstall can prune.
  • src/apm_cli/install/services.py — dispatch threads trust_canvas and an explicit is_first_party into the canvas call; integrate_local_content passes is_first_party=True; dependency/template/drift callers default to False. integrate_local_bundle enforces both gates and routes extensions/ only to Copilot.
  • src/apm_cli/bundle/unpacker.pyunpack_bundle gains trust_canvas and a canvas_blocked count; drops canvas paths unless both gates hold (fail closed).
  • src/apm_cli/bundle/plugin_exporter.py — collects extensions/ into the pack so apm pack round-trips a canvas (files stay inert until a consumer gates them).
  • src/apm_cli/models/validation.py_canvas_extension_names helper recognises canvas bundles so canvas-only packages are valid; a gated-executable warning is emitted for any package shipping canvas extensions.
  • src/apm_cli/commands/audit.py_deployed_canvas_bundles + _render_canvas_note list deployed canvas extension bundles as an informational note in text-format audit output.
  • core/experimental.py, commands/install.py, commands/pack.py, integration/dispatch.py, install/{context,request,pipeline,template,drift,phases/*}.py — flag registration, CLI flag, dispatch wiring, and pipeline plumbing.
  • Docs — new integrations/canvas.md, a matrix row in concepts/primitives-and-targets.md, and apm-usage skill updates (authoring, governance, install flag).

Diagrams

Legend: the two-gate decision every canvas deploy path runs; look first at the two diamonds — the experimental flag and the trust gate are independent, and a dependency must clear both.

flowchart TD
    A[Canvas found under extensions] --> B{canvas flag enabled?}
    B -->|no| S1[Skip silently: feature off]
    B -->|yes| C{first-party root or local?}
    C -->|yes| D[Deploy to .github/extensions]
    C -->|no, dependency| E{--trust-canvas-extensions set?}
    E -->|no| S2[Block: emit trust diagnostic]
    E -->|yes| D
    D --> F[Record deployed files in lockfile]
Loading

Legend: the source-to-session paths across install, --global, and pack; the project deploy target is .github/extensions/<name>/, the user-scope target is ~/.copilot/extensions/<name>/, Copilot only.

flowchart LR
    SRC[".apm/extensions/widget/extension.mjs"] -->|apm install| INT[CanvasIntegrator]
    SRC -->|apm pack| PACK[plugin_exporter] --> BUNDLE[bundle extensions tree]
    BUNDLE -->|apm install bundle| GATE[two-gate check]
    INT --> DEST[".github/extensions/widget/"]
    GATE --> DEST
    DEP["dependency canvas"] -->|apm install --global --trust-canvas-extensions| UDEST["~/.copilot/extensions/widget/"]
    DEST --> SESSION[Copilot CLI session after reload]
    UDEST --> SESSION
Loading

Trade-offs

  • Two gates, not one. Chose an independent trust flag over reusing the experimental flag as a security control, because the experimental subsystem explicitly forbids gating security on a user-writable flag. Cost: dependency canvases need an extra opt-in.
  • Copilot-only; user scope is dependency-only. Canvas is a Copilot CLI construct, so non-Copilot targets stay hard-unsupported. --global deploys only dependency-provided canvases (the package + install --global UX), which reuse the dependency-lockfile persistence and the user-scope sync_for_target prune path. First-party root .apm/extensions/ at user scope is blocked (the user-scope lockfile pipeline does not track it, so it would leak an executable bundle on uninstall), and a non-default $COPILOT_HOME is refused (APM only honours the default ~/.copilot). Full first-party / COPILOT_HOME user-scope support is a follow-up.
  • Drift replay omits canvases. Canvas files are tracked in the lockfile and removed on uninstall, but drift detection deliberately excludes them for now (documented follow-up) to avoid mis-reporting executable bundles.
  • Full discovery model deferred. No Canvas dataclass / apm list surfacing yet. Package validation recognises canvas extensions (canvas-only packages are valid; a gated-executable warning is emitted). apm audit (text format) lists deployed canvas bundles. Machine-readable audit formats (sarif/json/markdown) are unchanged.

Benefits

  1. A canvas authored with create-canvas is now installable via APM with zero manual file copying, at project scope or globally via --global.
  2. Executable third-party code cannot reach .github/extensions/ or ~/.copilot/extensions/ without an explicit, named opt-in — fail-closed on the integrator, offline-bundle, and unpack paths alike, and user scope always requires trust.
  3. A dependency cannot spoof first-party status (the _local sentinel bypass is closed); proven by a dedicated regression test.
  4. Zero behaviour change for every existing install when the flag is off (silent no-op, no skip-count inflation, no warning).

Validation

Full lint chain (all 7 checks) and the canvas + install suites are green; the --global addition adds 5 user-scope tests, validated end-to-end with an isolated HOME (install -> deploy, uninstall -> prune, no-trust -> block, non-default COPILOT_HOME -> refuse). Validate/audit awareness adds 10 unit tests covering canvas-only package validity, gated-executable warning, bundle-root derivation, and render note.

ruff check src/ tests/            -> All checks passed!
ruff format --check src/ tests/   -> 1227 files already formatted
pylint R0801 (min-similarity 10)  -> rated 10.00/10
scripts/lint-auth-signals.sh      -> [+] auth-signal lint clean

Scenario Evidence

# Scenario (user promise) Principle(s) Test(s) proving it Type
1 A dependency's canvas is blocked unless I explicitly trust it Secure by default tests/unit/integration/test_canvas_integrator.py::test_dispatch_dependency_named_local_is_not_first_party unit
2 My own (first-party) canvas deploys once the flag is on DevX tests/unit/integration/test_canvas_integrator.py::test_dispatch_first_party_flag_deploys unit
3 --trust-canvas-extensions + flag deploys a dependency canvas to .github/extensions/ DevX tests/unit/install/test_install_local_bundle.py::TestLocalBundleCanvasTrust::test_canvas_deployed_with_trust_and_flag integration
4 A vendored bundle cannot bypass the flag even with trust Secure by default tests/unit/test_unpacker.py::TestUnpackCanvasTrust::test_canvas_blocked_when_trusted_but_flag_off unit
5 apm pack round-trips a canvas into the bundle Portability tests/unit/bundle/test_plugin_exporter_canvas.py unit
6 apm uninstall removes a deployed canvas DevX tests/unit/integration/test_canvas_integrator.py::test_sync_only_removes_managed_subset unit
7 --global deploys a trusted dependency canvas to ~/.copilot/extensions/; first-party + non-default $COPILOT_HOME are blocked Secure by default tests/unit/integration/test_canvas_integrator.py::test_user_scope_dependency_deploys_with_trust, ::test_user_scope_first_party_blocked, ::test_user_scope_nondefault_copilot_home_blocked unit
8 apm uninstall --global prunes the user-scope canvas DevX tests/unit/integration/test_canvas_integrator.py::test_user_scope_sync_prunes_dependency_canvas unit
9 A canvas-only package passes validation (not mis-flagged empty) and emits a gated-executable warning DevX / Observability tests/unit/test_models_validation_rules.py::test_canvas_only_package_is_valid, ::test_canvas_emits_gated_executable_warning unit
10 apm audit (text) lists deployed canvas bundles as an informational note Observability tests/unit/test_audit_scan_and_render.py::TestRenderCanvasNote::test_emits_info_when_bundles_found unit

How to test

  • In a scratch repo, create .apm/extensions/widget/extension.mjs, run apm install with the flag off -- no .github/extensions/widget/ is created (silent no-op).
  • Run apm experimental enable canvas, then apm install -- .github/extensions/widget/extension.mjs appears and install hints to reload the Copilot session (/clear).
  • Add a dependency that ships a canvas, run apm install -- canvas is blocked with a trust diagnostic; re-run with --trust-canvas-extensions -- it deploys.
  • apm pack, then apm install <bundle> without trust -- canvas blocked; with --trust-canvas-extensions and the flag on -- deploys.
  • apm uninstall the package -- .github/extensions/widget/ is removed.
  • Package the canvas as a dependency, then apm install <dep> --global --trust-canvas-extensions -- ~/.copilot/extensions/widget/ appears; apm uninstall <dep> --global removes it. Without --trust-canvas-extensions, the global install is blocked.
  • Create a canvas-only package (.apm/extensions/widget/extension.mjs, valid apm.yml, no other primitives) and run validation -- is_valid=True, warning mentions --trust-canvas-extensions.
  • In a project with a deployed canvas dependency, run apm audit -- text output lists the deployed canvas bundle(s) after the summary.

Closes #1691

Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com

Copilot AI review requested due to automatic review settings June 7, 2026 00:47
sergio-sisternes-epam pushed a commit that referenced this pull request Jun 7, 2026
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

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.

Pull request overview

Adds an experimental “canvas” primitive to let APM packages ship GitHub Copilot CLI canvas extensions (bundles under .apm/extensions/<name>/) and deploy them to .github/extensions/<name>/, guarded by (1) the canvas experimental flag and (2) an explicit --trust-canvas-extensions opt-in for dependency-provided executable code. Wiring covers normal installs, offline bundle installs, and apm unpack, plus documentation and tests.

Changes:

  • Introduces CanvasIntegrator and data-driven dispatch/target mapping for a Copilot-only canvas primitive.
  • Plumbs --trust-canvas-extensions through install context/request/pipeline and enforces fail-closed behavior on bundle/unpack paths.
  • Adds unit tests + docs + changelog entry for the new primitive and gating model.

Reviewed changes

Copilot reviewed 35 out of 35 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/unit/test_unpacker.py Adds unpack-path tests for the two-gate canvas blocking/deploy behavior.
tests/unit/integration/test_data_driven_dispatch.py Updates dispatch/partition expectations to account for the new canvas primitive + counters.
tests/unit/integration/test_canvas_integrator.py New comprehensive unit suite for CanvasIntegrator behavior, gating, collisions, and sync removal.
tests/unit/install/test_install_local_bundle.py Adds tests ensuring offline bundle installs can’t smuggle canvases without both gates.
tests/unit/install/test_architecture_invariants.py Updates the commands/install.py line-budget to accommodate new option/plumbing.
tests/unit/commands/test_install_phase3.py Adds trust_canvas to InstallContext construction tests.
tests/unit/commands/test_install_context.py Extends InstallContext field/round-trip tests for trust_canvas.
tests/unit/commands/test_install_context_and_resolution.py Mirrors context resolution tests for trust_canvas.
tests/unit/bundle/test_plugin_exporter_canvas.py New tests ensuring apm pack preserves extensions/ content into bundles.
src/apm_cli/integration/targets.py Adds Copilot target mapping for canvas -> extensions/ and marks it unsupported at user scope.
src/apm_cli/integration/dispatch.py Registers canvas in the primitive dispatch table.
src/apm_cli/integration/canvas_integrator.py New integrator implementing discovery, validation, atomic deploy, trust gating, and sync removal for canvases.
src/apm_cli/install/template.py Wires optional canvas integrator into template materialization + delta accounting.
src/apm_cli/install/services.py Threads trust_canvas + is_first_party into dispatch and adds post-integrate hints for canvases.
src/apm_cli/install/service.py Threads trust_canvas from InstallRequest to the pipeline.
src/apm_cli/install/request.py Adds trust_canvas field describing the trust opt-in for dependency canvases.
src/apm_cli/install/pipeline.py Adds trust_canvas parameter and threads it through to request/context.
src/apm_cli/install/phases/targets.py Wires CanvasIntegrator into the per-target integrator set.
src/apm_cli/install/phases/local_content.py Treats .apm/extensions/ as local content worth integrating.
src/apm_cli/install/phases/integrate.py Accounts for canvases in root integration summary bookkeeping.
src/apm_cli/install/local_bundle_handler.py Threads trust_canvas into offline bundle install handler.
src/apm_cli/install/drift.py Excludes canvas deploy prefixes from drift replay comparison.
src/apm_cli/install/context.py Adds trust_canvas to the runtime install context.
src/apm_cli/core/experimental.py Registers the new canvas experimental flag and user-facing hint text.
src/apm_cli/commands/pack.py Adds apm unpack --trust-canvas-extensions and surfaces blocked-canvas messaging.
src/apm_cli/commands/install.py Adds apm install --trust-canvas-extensions and threads it into install context + local bundle install.
src/apm_cli/bundle/unpacker.py Adds trust_canvas to unpack and enforces the two-gate blocking + canvas_blocked reporting.
src/apm_cli/bundle/plugin_exporter.py Includes extensions/ in pack collection for .apm/ and root plugin formats.
packages/apm-guide/.apm/skills/apm-usage/package-authoring.md Documents authoring .apm/extensions/<name>/extension.mjs and deploy behavior.
packages/apm-guide/.apm/skills/apm-usage/governance.md Documents the trust model and fail-closed enforcement across install paths.
packages/apm-guide/.apm/skills/apm-usage/commands.md Updates CLI reference to mention --trust-canvas-extensions.
docs/src/content/docs/integrations/canvas.md New user-facing integration guide for canvas extensions.
docs/src/content/docs/concepts/primitives-and-targets.md Adds canvas to the primitives/targets model + matrix.
docs/astro.config.mjs Adds the new canvas guide page to the docs sidebar.
CHANGELOG.md Adds an Unreleased entry announcing the experimental canvas primitive.

Comment thread src/apm_cli/integration/canvas_integrator.py
Comment thread src/apm_cli/integration/canvas_integrator.py
Comment thread CHANGELOG.md Outdated
@github-actions

github-actions Bot commented Jun 7, 2026

Copy link
Copy Markdown

APM Review Panel: ship_with_followups

Canvas primitive ships a sound two-gate trust model; pre-draft-exit: fix security.md 'no code execution' claim, fill three missing CLI ref doc entries, and add contributor credit.

cc @sergio-sisternes-epam @danielmeppiel -- a fresh advisory pass is ready for your review.

All eight panelists converge cleanly: no blocking findings, no correctness regression, no architectural fault that compounds. The canvas trust model -- feature flag as the coarse gate, --trust-canvas-extensions as the explicit operator opt-in for dependency canvases, first-party deploy freely once the flag is on -- is principled, no-spoof-proof, and correctly threaded through InstallContext (supply-chain, auth-expert). Python-architect confirms the BaseIntegrator subclass pattern is followed; the DI inconsistency in integrate_local_content is a testability concern only, not a runtime defect. The overall shape of this PR is solid enough to land after the draft phase with targeted pre-exit fixes.

Two gaps stand above the rest on risk grounds. First, security.md now carries an actively false claim: 'APM does not execute scripts.' Canvas is exactly that -- executable Node.js deployed by APM. Doc-writer identified a double failure: the cross-link from canvas.md delivers no canvas content, and the statement it implicitly endorses is incorrect for the new surface. An enterprise auditor landing on that cross-link gets a false assurance picture. This is a documentation trust defect that must be corrected before the draft exits -- removing the dangling cross-link and adding a canvas exception callout to security.md under 'What APM does NOT do.' Second, the integration tier of the trust gate has zero test coverage. Supply-chain and test-coverage converge on this: for a surface that deploys executable code, APM's tier-floor matrix requires integration-with-fixtures. All existing coverage is unit-only (monkeypatched config, tmp_path). A future refactor mis-threading trust_canvas or is_first_party would not be caught until runtime. The drift-exclusion logic in drift.py:589-646 compounds this -- it has no regression trap at any tier, meaning a refactor could silently re-surface deployed canvas paths as orphaned findings in apm audit --check drift with no automated signal. Both carry evidence.outcome: missing on secure-by-default / governed-by-policy surfaces and rank above any opinion-tier recommended finding.

The remaining recommended gaps are real but lower urgency: three CLI reference docs are missing the --trust-canvas-extensions entry (install.md, experimental.md, unpack.md -- all static-tier evidence.outcome: missing), apm install silently accepts the flag when the canvas feature gate is off while apm unpack correctly emits a two-case diagnostic, the trust-block diagnostic fires at CATEGORY_WARNING instead of CATEGORY_POLICY (wrong visual weight for an executable-code security gate), the /clear session-reload hint in both canvas.md and services.py likely misleads users since /clear does not restart the CLI process or re-trigger extension discovery, and the CHANGELOG is missing by @sergio-sisternes-epam per project convention. All are addressable in the draft phase.

Dissent. No panelist disagreed with another on a severity call. The only mild divergence is python-architect flagging the integrate_local_content DI inconsistency at recommended while no other panelist amplified it. The code is correct and the pattern deviation does not compound at runtime -- I weight it as a testability nit and rank it below the security.md accuracy and integration-test gaps.

Aligned with: secure_by_default (two-gate model is sound and fail-closed: feature flag off means zero canvas behavior; first-party deploys freely once flag is on; dependency canvases require explicit --trust-canvas-extensions; gap: integration-tier test absent for executable-code deploy surfaces), governed_by_policy (experimental flag and --trust-canvas-extensions propagate correctly through InstallContext; disclosed gap: no apm-policy.yml integration yet), pragmatic_as_npm (zero behavior change when flag is off matches npm's opt-in model; consistency gap: apm install must match apm unpack's two-case diagnostic), oss_community_driven (CHANGELOG missing by @sergio-sisternes-epam, #1689 per project convention -- a one-line fix that matters for contributor trust).

Growth signal. Canvas is APM's first primitive that deploys executable runtime behavior -- Node.js extension code -- directly into the Copilot CLI extension ecosystem, not just config or context. The latent virality is significant: if create-canvas + APM becomes the standard distribution channel for Copilot CLI extensions inside organizations, APM gains adoption from Copilot power users who would otherwise never encounter it. Three pre-GA asks worth tracking on the roadmap: (1) a canvas-starter template in templates/ so the first-use experience has a working default rather than a directory tree to fill in blind, (2) a CHANGELOG session-reload note so users know a freshly deployed canvas requires a new session, and (3) a launch one-liner for social when the feature exits experimental. The positioning story to queue: 'build once with create-canvas, declare in apm.yml, everyone gets it with apm install' -- APM as the distribution layer for the Copilot CLI extension ecosystem.

Panel summary

Persona B R N Takeaway
Python Architect 0 1 2 CanvasIntegrator fits BaseIntegrator subclass pattern; integrate_local_content breaks DI by inlining CanvasIntegrator() instead of a parameter; canvas phase counters not accumulated in integrate.py.
CLI Logging Expert 0 3 3 Trust-block mis-categorized as CATEGORY_WARNING (wrong color/order), package name duplicated in rendered output, path-traversal warnings lose package attribution; 3 nits.
DevX UX Expert 0 4 2 Canvas primitive is well-designed but three CLI reference docs are missing the new flag/feature, and install silently swallows --trust-canvas-extensions when the feature gate is off.
Supply Chain Security Expert 0 3 2 Trust gate logic is sound and no-spoof proof is in place; two recommended gaps: no integration-tier test for the gate and is_canvas_bundle_path false-negative on crafted lockfile paths.
OSS Growth Hacker 0 3 2 Canvas ships clean but missing a starter template, CHANGELOG session-reload note, and any story angle for APM's first native Copilot-CLI-extension primitive.
Auth Expert 0 1 1 Canvas trust gate is sound: is_first_party is call-path-controlled only, trust_canvas propagates correctly through context, no auth tokens touched; comment imprecision on 'root/local' scope worth fixing.
Doc Writer 0 4 1 canvas.md code-accurate; security.md cross-link misleads (no canvas trust content, 'no code execution' claim now false); /clear hint suspect; CHANGELOG missing credit and blank line.
Test Coverage Expert 0 1 0 6 trust-gate scenarios have unit/fixture coverage; canvas drift-exclusion logic in drift.py (lines 589-646) has no test and will silently regress.

B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.

Top 5 follow-ups

  1. [Doc Writer] security.md 'no code execution' claim is now actively false for canvas -- remove cross-link and add canvas exception callout -- canvas.md links to security.md for the trust rationale, but security.md still states 'APM does not execute scripts.' Canvas deploys executable Node.js via APM. Enterprise auditors following that cross-link receive an actively false assurance picture. Fix: remove the dangling cross-link from canvas.md; add a canvas exception callout to security.md under 'What APM does NOT do.' This is the highest documentation trust risk in the PR and must land before the draft exits.

  2. [Supply Chain Security Expert] Add integration-with-fixtures test for the canvas trust gate across the full install pipeline -- All existing trust-gate coverage is unit-only (monkeypatched config, tmp_path). For a surface that deploys executable code, APM's tier-floor requires integration-with-fixtures. A future refactor mis-threading trust_canvas or is_first_party would not be caught until runtime. Add a tests/integration/ fixture-backed test that runs apm install without and with --trust-canvas-extensions against a real local dep shipping a canvas. Evidence.outcome: missing on secure-by-default and governed-by-policy surfaces.

  3. [DevX UX Expert] Three CLI reference docs missing --trust-canvas-extensions: install.md, experimental.md, unpack.md -- same-PR fix -- A CLI flag introduced in a PR with no corresponding reference doc entry is an incomplete DevX contract. All three carry evidence.outcome: missing on static tier. The flag was added in this PR and the doc entries should ship in the same PR.

  4. [Test Coverage Expert] Canvas drift-exclusion logic in drift.py has no regression-trap test at any tier -- drift.py:589-646 excludes canvas deploy paths from apm audit --check drift orphan detection. Zero test coverage found. Evidence.outcome: missing, integration-with-fixtures tier, cross-module surface (drift.py + canvas_integrator.py + KNOWN_TARGETS). A future refactor will silently re-surface deployed canvas paths as orphaned findings in apm audit with no automated signal. Suggested assertion: assert not any('.github/extensions' in f.path for f in findings).

  5. [CLI Logging Expert] Trust-block fires at CATEGORY_WARNING not CATEGORY_POLICY; apm install silently swallows --trust-canvas-extensions when canvas flag is off -- Two UX consistency gaps: (1) Blocking executable Node code from a dependency is a policy-level security gate -- it should render red [x] at CATEGORY_POLICY alongside MCP trust blocks, not yellow [!] at CATEGORY_WARNING. (2) apm unpack already emits the correct two-case diagnostic when the flag is passed with the feature gate off; apm install should do the same. Both are targeted fixes in canvas_integrator.py.

Architecture

classDiagram
    direction LR

    class BaseIntegrator {
        <<Template>>
        +check_collision(target, rel, managed, force) bool
        +normalize_managed_files(files) set
        +is_content_identical_to_source(target, src) bool
        +sync_remove_files(root, files, prefix) dict
        +cleanup_empty_parents(paths, stop_at) None
    }

    class CanvasIntegrator {
        <<ConcreteTemplate>>
        +find_canvas_bundles(package_path) list
        +integrate_canvases_for_target(target, pkg, root, ...) IntegrationResult
        +sync_for_target(target, pkg, root, managed) dict
        -_validate_canvas_name(name) None
        -_deploy_bundle(bundle, ext_dir, root, ...) str
        -_plan_bundle_files(bundle, canvas_root, ...) list
        -_emit_trust_block(bundles, pkg_name, ...) None
    }

    class PromptIntegrator {
        <<ConcreteTemplate>>
        +integrate_prompts_for_target(...) IntegrationResult
        +sync_for_target(...) dict
    }

    class IntegrationResult {
        <<ValueObject>>
        +files_integrated int
        +files_skipped int
        +files_adopted int
        +target_paths list
        +links_resolved int
    }

    class IntegratorBundle {
        <<ValueObject>>
        +prompt BaseIntegrator
        +agent BaseIntegrator
        +skill BaseIntegrator
        +instruction BaseIntegrator
        +command BaseIntegrator
        +hook BaseIntegrator
        +canvas BaseIntegrator_or_None
    }

    class TargetProfile {
        <<ValueObject>>
        +name str
        +root_dir str
        +primitives dict
        +for_scope(user_scope) TargetProfile
    }

    class PrimitiveMapping {
        <<ValueObject>>
        +subdir str
        +deploy_root str
    }

    BaseIntegrator <|-- CanvasIntegrator : extends
    BaseIntegrator <|-- PromptIntegrator : extends
    CanvasIntegrator ..> IntegrationResult : returns
    IntegratorBundle o-- BaseIntegrator : canvas optional
    TargetProfile *-- PrimitiveMapping : primitives canvas
    CanvasIntegrator ..> TargetProfile : reads name primitives

    class CanvasIntegrator:::touched
    class IntegratorBundle:::touched
    classDef touched fill:#fff3b0,stroke:#d47600
Loading
flowchart TD
    A["apm install command"] --> B["run_install_pipeline\ntrust_canvas stored in InstallContext"]
    B --> C["targets.py: ctx.integrators[canvas] = CanvasIntegrator()"]
    B --> D["integrate.py run()"]
    D --> E["for dep in deps_to_install"]
    E --> F["integrate_package_primitives\nIntegratorBundle(canvas=ctx.integrators.get('canvas'))"]
    F --> G["dispatch: prim_name == 'canvas'"]
    G --> K["CanvasIntegrator.integrate_canvases_for_target\ntrust_canvas=ctx.trust_canvas, is_first_party=False"]
    K --> L{is_enabled('canvas')?}
    L -- NO --> M["return empty"]
    L -- YES --> N{"target == 'copilot'\nAND scope != USER"}
    N -- NO --> M
    N -- YES --> O["find_canvas_bundles\n.apm/extensions/ walk"]
    O --> P{bundles found?}
    P -- NO --> M
    P -- YES --> Q{is_first_party OR trust_canvas?}
    Q -- NO --> R["_emit_trust_block diagnostic\nreturn empty"]
    Q -- YES --> S["_deploy_bundle: validate + plan + copy"]
    S --> AA["[FS] shutil.copyfile to .github/extensions/name/"]
    D --> BB["_integrate_root_project\nis_first_party=True, bypasses trust gate"]
    BB --> K
Loading

Recommendation

The canvas primitive's security model is validated: two-gate trust, no-spoof proof, fail-closed defaults. Recommend addressing three items before the draft exits: (1) correct the security.md 'no code execution' claim -- it is actively false for canvas and must not reach enterprise auditors unpatched; (2) add the three missing --trust-canvas-extensions entries to install.md, experimental.md, and unpack.md -- a flag with no reference doc is an incomplete DevX contract in the same PR; (3) add by @sergio-sisternes-epam, #1689 to the CHANGELOG entry per project convention. The integration-tier trust gate test and the drift.py regression trap are the highest-signal post-merge follow-ups -- queue them as the first issues opened after merge so they do not drift.


Full per-persona findings

Python Architect

  • [recommended] integrate_local_content creates CanvasIntegrator() internally instead of accepting a canvas_integrator parameter, breaking the consistent DI pattern of all 6 sibling integrators at src/apm_cli/install/services.py:580
    Every other integrator is injected through IntegratorBundle. Canvas alone is created inline, so the call site in phases/integrate.py:335 cannot reuse ctx.integrators['canvas'], and test authors cannot substitute a mock without patching the class directly. This violates the Inject collaborators via constructor principle already working for the other six integrators.
    Suggested: Add canvas_integrator: Any = None to integrate_local_content's keyword args and use (canvas_integrator or CanvasIntegrator()) in the IntegratorBundle constructor. Update the call in phases/integrate.py to pass canvas_integrator=ctx.integrators.get('canvas').

  • [nit] canvas count in deltas is silently dropped; no total_canvases_integrated on InstallContext; finalize verbose log has no canvas summary line at src/apm_cli/install/phases/integrate.py:595
    template.py populates deltas['canvases'] but integrate.py never accumulates it and InstallContext has no total_canvases_integrated field, diverging from the established hook/command/instruction pattern.

  • [nit] canvas_root.parent.resolve() / name in _deploy_bundle is equivalent to canvas_root.resolve() but obscures intent at src/apm_cli/integration/canvas_integrator.py:298
    Simplify to ensure_path_within(canvas_root.resolve(), extensions_dir.resolve()) to make the containment check self-evident.

CLI Logging Expert

  • [recommended] Trust-block diagnostic uses CATEGORY_WARNING instead of CATEGORY_POLICY, understating severity and rendering in wrong position at src/apm_cli/integration/canvas_integrator.py:416
    _emit_trust_block calls diagnostics.warn() (CATEGORY_WARNING), which renders yellow [!] AFTER collision/overwrite warnings. Blocking executable Node code from a dependency is a policy-level security gate -- identical to an MCP trust block. Using CATEGORY_POLICY would render red [x], appear earlier in the summary, and match how other executable-code gates are surfaced.
    Suggested: Change diagnostics.warn(message=..., package=pkg) to diagnostics.policy(message=..., package=pkg, severity='block'). Match the MCP trust block pattern.

  • [recommended] Package name appears twice in rendered trust-block output: once in the [pkg] prefix, once embedded in the message body at src/apm_cli/integration/canvas_integrator.py:418
    _render_warning_group prepends [d.package] automatically, so the embedded "from 'pkg'" phrase is redundant noise in the rendered line.
    Suggested: Remove the embedded "from '{pkg}'" phrase from the message body.

  • [recommended] _plan_bundle_files emits path-traversal warnings with empty package attribution, making security events untraceable in multi-package installs at src/apm_cli/integration/canvas_integrator.py:390
    _deploy_bundle receives package_name but does not forward it to _plan_bundle_files. The _warn call passes empty string, so the rendered warning has no [pkg] prefix.
    Suggested: Add package_name param to _plan_bundle_files and pass it through from _deploy_bundle.

  • [nit] _warn fallback calls _rich_warning(message) without symbol='warning', dropping the [!] prefix when no DiagnosticCollector is present at src/apm_cli/integration/canvas_integrator.py:433

  • [nit] --trust-canvas-extensions help text says 'Requires the canvas experimental feature' without telling the user the enable command at src/apm_cli/commands/install.py:926

  • [nit] The /clear hint may mislead users: /clear clears session context but may not trigger Copilot extension re-discovery at src/apm_cli/install/services.py:161
    /clear keeps the session alive; extension discovery happens at session start. 'or restart' should lead the hint.

DevX UX Expert

  • [recommended] apm install CLI reference missing --trust-canvas-extensions in the Policy and trust options table at docs/src/content/docs/reference/cli/install.md:56
    docs/src/content/docs/reference/cli/install.md has a 'Policy and trust' section listing --trust-transitive-mcp. --trust-canvas-extensions belongs there and is absent. A CLI change not reflected in the reference doc in the same PR is incomplete per DevX contract.
    Proof (missing at): docs/src/content/docs/reference/cli/install.md -- proves: apm install --help and the CLI reference page agree on available flags [devx]

  • [recommended] Experimental flags reference missing the canvas flag in the Available flags table at docs/src/content/docs/reference/experimental.md:170
    docs/src/content/docs/reference/experimental.md lists every registered flag but canvas is absent despite being registered in experimental.py.
    Proof (missing at): docs/src/content/docs/reference/experimental.md -- proves: apm experimental list output and the experimental flags reference page agree on available flags [devx]

  • [recommended] apm unpack CLI reference missing --trust-canvas-extensions in its options table at docs/src/content/docs/reference/cli/unpack.md:28
    pack.py adds --trust-canvas-extensions to unpack_cmd but the unpack reference page has no entry.
    Proof (missing at): docs/src/content/docs/reference/cli/unpack.md -- proves: apm unpack --help and the CLI reference page agree on available flags [devx]

  • [recommended] apm install silently accepts --trust-canvas-extensions when the canvas feature flag is off, giving the user no feedback that the flag did nothing at src/apm_cli/integration/canvas_integrator.py:164
    canvas_integrator returns empty before any trust_canvas check when the flag is off. apm unpack (pack.py:713-718) correctly emits a two-case diagnostic; apm install should be consistent.
    Suggested: Before the early return at line 164, add: if not is_enabled('canvas') and trust_canvas: emit a warning telling the user that --trust-canvas-extensions has no effect until 'apm experimental enable canvas' is run.
    Proof (missing at): tests/unit/integration/test_canvas_integrator.py -- proves: apm install --trust-canvas-extensions emits a hint when the canvas feature flag is off [devx]

  • [nit] [experimental] prefix on --trust-canvas-extensions help text is inconsistent with the sibling --trust-transitive-mcp flag at src/apm_cli/commands/install.py:926

  • [nit] pack.md plugin bundle output format section does not list extensions/ alongside the other plugin-native subdirs at docs/src/content/docs/reference/cli/pack.md:104

Supply Chain Security Expert

  • [recommended] No integration-with-fixtures test for the canvas trust gate in the full install pipeline
    All gate tests are unit-level (monkeypatched config cache, tmp_path fixtures, no real CLI invocation). For a surface that deploys executable code, APM's tier-floor matrix requires integration-with-fixtures. A future refactor mis-threading trust_canvas or is_first_party would not be caught until runtime.
    Suggested: Add a tests/integration/ fixture-backed test that: (a) runs apm install without --trust-canvas-extensions against a dep shipping a canvas and asserts .github/extensions/ is absent, (b) re-runs with --trust-canvas-extensions and asserts the file is deployed.
    Proof (missing at): tests/integration/ -- proves: Dependency canvas is blocked by default and deploys only with explicit operator opt-in across the full CLI surface [secure-by-default,governed-by-policy]

  • [recommended] is_canvas_bundle_path false-negative: crafted bundle lockfiles can route canvas content through non-standard paths that evade the gate at src/apm_cli/integration/canvas_integrator.py:66
    is_canvas_bundle_path returns True only when 'extensions' is (a) the first path component or (b) immediately after a dot-dir. A bundle lockfile with entries like 'src/extensions/evil/extension.mjs' returns False, bypassing the block in unpacker.py:167-173 and services.py:724-728.
    Suggested: Also flag any path whose final filename is 'extension.mjs', OR document the narrow scope and add a test for the False case.
    Proof (missing at): tests/unit/integration/test_canvas_integrator.py::test_is_canvas_bundle_path
    ('src/extensions/evil/extension.mjs', False) # not currently in parametrize list

  • [recommended] shutil.copyfile in _deploy_bundle follows destination symlinks, creating a TOCTOU path-escape window at src/apm_cli/integration/canvas_integrator.py:358
    shutil.copyfile(src, dest) follows dest if it is a symlink to a regular file (dest.is_file() returns True for symlink-to-file). The ensure_path_within check runs at planning time against the unresolved dest. A symlink placed between planning and copy could redirect a canvas file write outside canvas_root.
    Suggested: Add a per-file symlink check immediately before the copy: if dest.is_symlink(): warn and skip. Or add a comment accepting the TOCTOU risk (attacker needs pre-existing write access).

  • [nit] ensure_path_within(canvas_root.parent.resolve() / name, extensions_dir.resolve()) is logically equivalent to 'always True' and should be commented as defense-in-depth at src/apm_cli/integration/canvas_integrator.py:298

  • [nit] shutil.copyfile vs shutil.copy2 choice is security-intentional (avoids setuid/setgid bits) but undocumented at src/apm_cli/integration/canvas_integrator.py:358
    Suggested: Add comment: 'Intentionally copyfile (content only) rather than copy2: avoids propagating setuid/setgid bits or special modes from package-supplied canvas source files.'

OSS Growth Hacker

  • [recommended] No starter template for canvas authoring -- first-use experience requires filling in values without a working default at docs/src/content/docs/integrations/canvas.md:39
    The canvas.md 'Author a canvas' section shows a directory tree but no file contents for even a minimal extension.mjs. templates/ has hello-world and policy starters but nothing for canvas.
    Suggested: Add templates/canvas-starter/ with a minimal extension.mjs (5-line hello-world canvas) and a matching apm.yml that declares the copilot target. Link it from canvas.md.

  • [recommended] CHANGELOG entry omits the session-reload requirement -- will generate 'my canvas is not showing up' support noise at CHANGELOG.md:12
    The CHANGELOG entry describes what the primitive does but omits that a freshly deployed canvas is not picked up mid-session and requires a reload or restart.
    Suggested: Append to the canvas bullet: 'After install, reload your Copilot session (/clear or restart) -- Copilot CLI discovers extensions at session start only.'

  • [recommended] No launch narrative for APM's first native Copilot-CLI-extension primitive -- new surface area with no story angle
    Canvas is APM's first primitive that extends the Copilot CLI itself. No README mention, no quickstart callout, no repostable one-liner. The CHANGELOG entry reads as an engineer's note; nothing in it is repostable by a Copilot power user.
    Suggested: Prepare a one-liner for social, add a one-sentence mention in README under 'What APM manages', and queue a quickstart callout for when the feature exits experimental.

  • [nit] canvas.md description is factual but not conversion-oriented for doc site searchers at docs/src/content/docs/integrations/canvas.md:3

  • [nit] CHANGELOG entry leaves 'canvas' unexplained for users who have not seen the create-canvas skill at CHANGELOG.md:12

Auth Expert

  • [recommended] 'root/local' comment in request.py and context.py overstates first-party scope for canvas at src/apm_cli/install/request.py:69
    InstallRequest line 69 and InstallContext lines 67-68 document the first-party exemption as applying to 'root/local' canvases. In practice, only the root project's .apm/ directory receives is_first_party=True (via integrate_local_content). Local-path dependencies declared in apm.yml (path: ../my-pkg) flow through integrate_package_primitives with is_first_party defaulting to False, so their canvases ARE subject to the trust gate. The word 'local' will mislead maintainers.
    Suggested: Change 'first-party (root/local)' to 'first-party (root project .apm/)' in both request.py and context.py.

  • [nit] integrate_local_bundle trust gate comment could note the absence of is_first_party semantics for offline bundles at src/apm_cli/install/services.py:724

Doc Writer

  • [recommended] canvas.md cross-link to security.md delivers neither the promised content nor an accurate security picture at docs/src/content/docs/integrations/canvas.md:110
    canvas.md line 110-112 links to security.md for 'the trust rationale'. Two problems: (1) security.md has zero canvas content. (2) security.md states 'No arbitrary code execution. APM does not execute scripts.' Canvas is exactly that: executable Node.js deployed by APM to .github/extensions/. An enterprise auditor gets an actively false picture.
    Suggested: Remove the dangling cross-link from canvas.md; add a canvas exception callout to security.md under 'What APM does NOT do'.

  • [recommended] '/clear' session-reload hint in Install section is likely incorrect and will mislead users at docs/src/content/docs/integrations/canvas.md:65
    The /clear slash command clears conversation context but does not restart the Copilot CLI process or re-trigger extension discovery. canvas.md itself states discovery happens at session START, which /clear cannot trigger.
    Suggested: Replace 'reload your Copilot session (/clear) or restart it' with 'start a new Copilot CLI session (exit and relaunch)'.

  • [recommended] CHANGELOG canvas entry missing contributor attribution for @sergio-sisternes-epam at CHANGELOG.md:17
    changelog.instructions.md rule: 'Credit external contributors inline: -- by @username (#PR_NUMBER).' Every other externally-authored entry includes the credit.
    Suggested: Change the closing of the canvas bullet to: '...stays blocked until you pass --trust-canvas-extensions. (by @sergio-sisternes-epam, feat(install): experimental Copilot canvas extensions #1689)'

  • [recommended] canvas.md Scope and limitations omits the missing policy-file control, which matters to the governance audience at docs/src/content/docs/integrations/canvas.md:101
    Teams using apm-policy.yml to govern trust surfaces would reasonably expect a new trust-gated primitive to be controllable via policy. The omission should be disclosed up-front.
    Suggested: Add: 'No policy-file control yet. Canvas trust is controlled only by the --trust-canvas-extensions CLI flag; governing it via apm-policy.yml is planned but not part of this experimental release.'

  • [nit] Missing blank line between the Fixed bullet and ### Changed heading in CHANGELOG [Unreleased] at CHANGELOG.md:26

Test Coverage Expert

  • [recommended] Canvas drift-exclusion logic in drift.py has no regression-trap test at src/apm_cli/install/drift.py:589
    _canvas_deploy_prefixes (drift.py:589-605) and its application in diff_scratch_against_project (drift.py:638-646) exclude canvas deploy paths from the orphan/modified check in 'apm audit --check drift'. grep for '_canvas_deploy_prefixes|drift.*canvas|canvas.*orphan' in tests/ returned zero results at any tier. The surface is cross-module, placing the tier floor at integration-with-fixtures.
    Suggested: Add to tests/unit/install/test_drift.py a test that asserts deployed canvas paths are not reported as orphaned or drift findings after apm install deploys them.
    Proof (missing at): tests/unit/install/test_drift.py::test_canvas_paths_excluded_from_drift_orphan_check
    assert not any('.github/extensions' in f.path for f in findings)
    -- proves: apm audit --check drift does not report a deployed canvas extension as orphaned after apm install deploys it [devx]

This panel is advisory. It does not block merge. Re-apply the
panel-review label after addressing feedback to re-run.

Warning

Firewall blocked 1 domain

The following domain was blocked by the firewall during workflow execution:

  • pypi.org

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "pypi.org"

See Network Configuration for more information.

Generated by PR Review Panel for issue #1689 · sonnet46 28.9M ·

@github-actions github-actions Bot removed the panel-review Trigger the apm-review-panel gh-aw workflow label Jun 7, 2026
sergio-sisternes-epam pushed a commit that referenced this pull request Jun 9, 2026
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@sergio-sisternes-epam sergio-sisternes-epam force-pushed the sergio-sisternes-epam/experimental-canvas-extensions branch from fb52e02 to ff5e1a3 Compare June 9, 2026 14:03
@sergio-sisternes-epam sergio-sisternes-epam marked this pull request as ready for review June 9, 2026 14:03
@sergio-sisternes-epam sergio-sisternes-epam added the panel-review Trigger the apm-review-panel gh-aw workflow label Jun 9, 2026
@github-actions github-actions Bot mentioned this pull request Jun 9, 2026
@sergio-sisternes-epam

Copy link
Copy Markdown
Collaborator Author

Panel Review Response (aecdca8)

Verdict: ship_with_followups -- agreed. All foldable findings addressed in commit aecdca8.

Folded (14 items)

# Area Finding Fix
1 security.md "No arbitrary code execution" false for canvas Added canvas exception callout with flag + trust-gate details
2 canvas.md Cross-link to security.md inaccurate Fixed to point to primitives-and-targets matrix
3 canvas.md "/clear" reload hint incorrect Changed to "start a new Copilot CLI session (exit and relaunch)"
4 canvas.md Missing policy-file limitation Added disclosure that policy-file control is not yet available
5 install.md Missing --trust-canvas-extensions in table Added row after --trust-transitive-mcp
6 experimental.md Missing canvas flag in Available flags Added row with cross-link to canvas docs
7 unpack.md Missing --trust-canvas-extensions Added row to options table
8 pack.md Missing extensions/ in plugin output Added to subdirs list
9 CHANGELOG.md Missing contributor attribution Added (by @sergio-sisternes-epam, #1689) to all 3 entries
10 canvas_integrator.py shutil.copyfile follows dest symlinks (TOCTOU) Added pre-copy dest.is_symlink() guard
11 canvas_integrator.py _plan_bundle_files missing package_name Threaded package_name parameter through
12 canvas_integrator.py Redundant from 'pkg' in trust-block Fixed to use raw package_name for package= field
13 request.py "root/local" comment overstates scope Changed to "root project .apm/"
14 context.py Same "root/local" comment Changed to "root project .apm/"

Deferred (post-merge follow-ups)

  • Integration-with-fixtures test for canvas trust gate
  • Canvas drift-exclusion regression-trap test
  • DI pattern for CanvasIntegrator (internal construction)
  • Starter template for canvas authoring
  • Launch narrative / README mention
  • CATEGORY_POLICY for trust-block diagnostic
  • Silent --trust-canvas-extensions feedback when flag off
  • is_canvas_bundle_path false-negative for non-standard paths
  • InstallContext.total_canvases_integrated field
  • shutil.copyfile vs copy2 intentional choice comment

Sergio Sisternes and others added 6 commits June 9, 2026 15:56
Add an experimental, Copilot-only "canvas" primitive so a package can ship
a GitHub Copilot CLI extension under .apm/extensions/<name>/ and have
`apm install` / `apm pack` deploy it verbatim to .github/extensions/<name>/
where Copilot CLI discovers it in-session.

Two independent gates protect the surface:

- Experimental feature flag `canvas` (feature availability, not security):
  the primitive type is inert unless the operator runs
  `apm experimental enable canvas`.
- Trust gate for dependency-provided canvases (executable extension.mjs
  code): blocked by default, opt-in via `--trust-canvas-extensions`,
  mirroring `--trust-transitive-mcp`. First-party (root/local) canvases
  deploy freely once the flag is on.

First-party status is decided by the call path (the integrate_local_content
signal), never inferred from a package-name string a dependency could spoof.
The flag + trust gates are enforced on every path that materialises canvas
files -- the CanvasIntegrator, the offline local-bundle install, and
`apm unpack` -- so a vendored bundle cannot smuggle executable canvas code
past either gate. Canvas deploys are atomic per bundle (plan -> collision
check -> adopt-if-identical -> copy) and never overwrite a pre-existing
directory even under --force.

Copilot-only, project-scope MVP: the canvas PrimitiveMapping lives solely
on the copilot target; user scope and non-Copilot targets are unsupported.
Drift replay deliberately omits canvases (deferred follow-up). After a
canvas deploys, install hints that the Copilot session must be reloaded
(/clear) or restarted to pick it up.

Docs: new integrations/canvas.md page, primitives-and-targets matrix row,
and apm-usage skill updates (authoring, governance, install flag).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… scope)

Extend the experimental Copilot canvas primitive to deploy at user scope so a
canvas is available in every Copilot session, not just one repo. Copilot CLI
scans both `<git-root>/.github/extensions/` and `~/.copilot/extensions/`; APM
now targets the latter on `apm install --global`.

Bounded, leak-safe slice: only dependency-provided canvases deploy globally
(the real "package + install --global" UX), which reuses the existing
dependency-lockfile persistence and the existing user-scope sync_for_target
prune path. First-party root `.apm/extensions/` at user scope stays blocked
because the user-scope lockfile pipeline does not track it (would leak an
executable bundle on uninstall).

Security: a global canvas has full-account blast radius, so
`--trust-canvas-extensions` is always required at user scope. A non-default
`$COPILOT_HOME` is refused (APM only honors the default `~/.copilot`, which
keeps the lockfile path home-relative and prunable).

- targets.py: drop "canvas" from copilot `unsupported_user_primitives` so the
  mapping survives `for_scope(user_scope=True)` (root_dir=.copilot,
  resolved_deploy_root=None -> clean home-relative lockfile encoding).
- canvas_integrator.py: replace the blanket user-scope no-op with scope-aware
  logic (COPILOT_HOME guard, first-party-at-user block, always-require-trust);
  add `_copilot_home_is_nondefault` helper.
- install.py: note `--global` behaviour in `--trust-canvas-extensions` help.
- Tests: user-scope dependency deploy, trust-required, first-party block,
  non-default COPILOT_HOME block, user-scope uninstall prune.
- Docs + apm-usage skill + CHANGELOG updated.

Refs #1691

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Validation: canvas-only packages are no longer mis-flagged as empty.
A warning notes they ship executable code gated by the canvas
experimental flag and --trust-canvas-extensions.

Audit: text-format output lists deployed canvas extension bundles
as an informational note after the summary (lockfile-scan path only;
machine-readable formats unchanged).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- find_canvas_bundles: reject symlinked base dir, verify resolved
  child stays within package_path (containment check).
- sync_for_target: filter removed_paths through validate_deploy_path
  before cleanup_empty_parents to prevent traversal via tampered
  managed_files.
- Collapse multi-line CHANGELOG entries to single-line bullets.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- security.md: add canvas exception to 'no arbitrary code execution' claim
- canvas.md: fix /clear hint, add policy-file limitation, fix cross-link
- install.md: add --trust-canvas-extensions to policy table
- experimental.md: add canvas flag to Available flags table
- unpack.md: add --trust-canvas-extensions to options table
- pack.md: add extensions/ to plugin bundle output section
- canvas_integrator.py: pre-copy symlink TOCTOU guard, thread
  package_name into _plan_bundle_files, fix trust-block pkg duplication
- request.py + context.py: fix 'root/local' comment to 'root project .apm/'
- CHANGELOG.md: add contributor attribution to canvas entries

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@sergio-sisternes-epam sergio-sisternes-epam force-pushed the sergio-sisternes-epam/experimental-canvas-extensions branch from aecdca8 to 1982630 Compare June 9, 2026 14:58
The rebase that merged main's hook-display helper with our canvas
helpers pushed services.py to 1003 LOC, exceeding the architecture
invariant test budget of 1000. Compact two helper docstrings to
single-line form -- no behaviour change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@danielmeppiel danielmeppiel added panel-review Trigger the apm-review-panel gh-aw workflow and removed panel-review Trigger the apm-review-panel gh-aw workflow labels Jun 9, 2026
@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown

APM Review Panel: needs_rework

PR #1689 lands the canvas primitive -- APM's first Copilot-native extension mechanism, giving AI IDE tooling a manifest-driven, dual-gated deployment path -- but ships with a one-line case-folding defect that bypasses the trust gate on macOS and Windows and must be corrected before merge.

cc @sergio-sisternes-epam @danielmeppiel -- a fresh advisory pass is ready for your review.

The panel converges on a positive architectural assessment with one hard blocker. CanvasIntegrator's dual-gate design (experimental flag plus --trust-canvas-extensions) is the correct security posture for executable dependency code, and the supply-chain-security-expert confirmed that is_first_party cannot be spoofed via the registry path. The confirmed bypass is in is_canvas_bundle_path(): the literal seg != 'extensions' comparison allows a tarball with Extensions/myext/extension.mjs (capital-E) to deploy unsigned Node.js code on macOS HFS+ and Windows NTFS without the trust flag. The fix is seg.lower() on one comparison in canvas_integrator.py:91 and a matching guard in integrate_local_bundle. The question of whether experimental status changes the merge calculus has a clear answer: it does not. The bypass targets the offline bundle path (apm install <bundle.tar.gz>), the highest-risk supply-chain injection surface regardless of feature maturity, and the fix is one line. "Experimental" describes discoverability and API stability, not the security obligation when unauthorized executable code can be deployed without operator consent.

Secondary signals are directionally consistent and collectively manageable. The test-coverage-expert found full unit-tier coverage across all 10 declared scenarios but zero integration-with-fixtures evidence for the canvas deploy pipeline and zero test coverage for _canvas_deploy_prefixes in drift.py -- the latter being a high-blast-radius regression trap: if that function regresses, every subsequent apm install will emit false drift across all primitives. The doc-writer confirmed a dead link to a non-existent create-canvas skill on canvas.md:25 and a CHANGELOG PR number mismatch (#1691 vs #1689). These are not individually blocking, but a broken link on the primary guide page for a feature's first landing signals incompleteness to early adopters who will encounter it before they encounter the code. The python-architect's TOCTOU finding (_deploy_bundle leaving partial bundles on disk) and the supply-chain-security-expert's corroborating recommended on the same surface are real, but they are pre-existing structural patterns in the integrator base and not canvas-specific blockers.

The growth-hacker signal is genuinely bullish: canvas is a moat-deepening primitive that no incumbent package manager exposes, and the narrative (APM deploys Copilot extensions as a first-class installable) is the strongest positioning hook in this release cycle. The immediate growth gap is that this story is invisible at the doc layer -- what-is-apm.md's primitive table omits canvas entirely, and the CHANGELOG entry is maintainer-facing prose with no shareable hook. The minimum bar for merge is: (1) the seg.lower() security fix, (2) the dead link correction on canvas.md:25, and (3) the CHANGELOG PR number correction. All other findings graduate to tracked follow-ups. The security fix is a one-commit change; once it lands, this PR is ready.

Dissent. No panelists disagreed on severity for the BLOCKING supply-chain finding. The only meaningful divergence is between cli-logging-expert (recommended severity: --trust-canvas-extensions help text at 290 chars wraps to 7 lines) and devx-ux-expert (nit severity: four sentences vs --trust-transitive-mcp single line) on the same flag. Both observations are correct; cli-logging-expert's recommended classification is the right call -- 290-char wrapped help text degrades the cognitive load of apm install --help for every user, not only canvas authors.

Aligned with: Portable by manifest (bundles tracked in apm.lock.yaml, reproducible across machines); Secure by default (dual gate is correct posture; case-folding defect must be corrected before merge); Governed by policy (trust scope asymmetry between --trust-canvas-extensions and --trust-transitive-mcp is undocumented -- governance documentation debt to close before GA); Multi-harness multi-host (canvas correctly scoped to Copilot CLI only, no cross-harness leakage); OSS community-driven (community canvas contribution path is undocumented -- must be addressed before the feature can drive contributor adoption); Pragmatic as npm (one flag + one trust assertion to deploy; silent no-op on flag-off is the one ergonomic gap to close).

Growth signal. Canvas is the highest-value narrative hook in this release cycle: APM becomes the only package manager that can deploy Copilot extensions as a first-class installable primitive. The immediate growth gap is that this story is invisible at the doc layer -- what-is-apm.md's primitive table omits canvas entirely, so a developer landing on the site after reading the CHANGELOG will find no confirmation that canvas is a real primitive. The CHANGELOG entry has no shareable hook, which means the feature will not drive organic discovery on release day. The create-canvas to apm install to Copilot-discovers workflow exists in code but is never demonstrated end-to-end. Closing three gaps before or alongside the merge -- adding canvas to the what-is-apm.md primitive table, rewriting the CHANGELOG entry with a user-facing narrative hook, and publishing a minimal end-to-end walkthrough -- will determine whether this feature drives net-new adopters or remains invisible. The double-experimental signaling (sidebar badge AND caution callout in canvas.md) should be collapsed to a single signal to avoid discouraging the early adopters this feature most needs.

Panel summary

Persona B R N Takeaway
Python Architect 0 3 3 CanvasIntegrator is a well-constructed BaseIntegrator extension; dual-gate design and atomic bundle pre-pass are sound; three gaps: missing verbose canvas total, partial-bundle TOCTOU, singular dispatch key
CLI Logging Expert 0 4 2 All strings are ASCII-clean; four UX gaps: 290-char help text, duplicated warning prefix, [i] on every audit list item, reload hint fires on adopt-only reinstalls
DevX UX Expert 0 3 3 Architecturally sound trust model; three UX gaps: silent skip on flag-off gives authors zero signal, canvas missing from CLI experimental reference doc, trust scope asymmetry undocumented
Supply Chain Security Expert 1 2 2 Two-gate model is sound and is_first_party is non-spoofable; one confirmed trust gate bypass via mixed-case Extensions/ on case-insensitive filesystems; fix is seg.lower()
OSS Growth Hacker 0 4 2 Moat-deepening primitive with strong narrative potential; four growth gaps: no end-to-end workflow demo, canvas absent from what-is-apm.md, no installed-canvas discoverability path, CHANGELOG entry is maintainer-facing
Auth Expert -- -- -- No auth surface touched; trust_canvas is a local-deployment bool passthrough with no auth or network components
Doc Writer 0 4 4 canvas.md is structurally sound; four substantive gaps: dead create-canvas link, silent no-op undisclosed, audit.md not updated, CHANGELOG PR number mismatch
Test Coverage Expert 0 3 0 All 10 unit scenarios covered; three gaps: install pipeline unit-only (integration-with-fixtures floor not met), _canvas_deploy_prefixes in drift.py has zero tests, lockfile round-trip for uninstall unverified
Performance Expert 0 2 2 Flag-OFF path is well-optimized (is_enabled is cached); two flag-ON gaps: bundle.rglob has no file count ceiling, adopt-detect reads full file content without size pre-screen

B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.

Top 5 follow-ups

  1. [Supply Chain Security Expert] (blocking-severity) Fix the case-folding defect in is_canvas_bundle_path(): change seg != 'extensions' to seg.lower() != 'extensions' at canvas_integrator.py:91, apply the matching guard in integrate_local_bundle, and verify against a fixture tarball with a capital-E Extensions/ path on a case-insensitive filesystem. -- Confirmed trust gate bypass on macOS HFS+ and Windows NTFS; a tarball with Extensions/myext/extension.mjs deploys unsigned executable Node.js code without --trust-canvas-extensions. The fix is one line; there is no architectural justification for deferring it.
  2. [Test Coverage Expert] Add an integration-with-fixtures test for the canvas deploy pipeline: install a fixture canvas bundle, assert deployed paths exist under .github/extensions/, assert those paths appear in apm.lock.yaml, and assert that apm uninstall removes them cleanly. -- All 10 unit scenarios pass but the APM install-pipeline floor requires integration-with-fixtures evidence. A new executable-code deployment path with no integration test is a regression trap that will surface as an incident, not a CI failure.
  3. [Test Coverage Expert] Add tests for _canvas_deploy_prefixes in drift.py covering: (1) deployed canvas paths are excluded from the drift report, and (2) a path matching a canvas prefix pattern but not a canvas deployment is correctly flagged. -- Zero test coverage confirmed by grep across tests/. If this function regresses, every apm install after a canvas deploy will emit false drift across all primitive types -- a high-blast-radius silent failure.
  4. [Doc Writer] Replace the dead create-canvas skill link on canvas.md:25 with a concrete placeholder or forthcoming note, and correct the CHANGELOG PR number mismatch (second canvas entry attributes global install to Experimental canvas: support --global install to ~/.copilot/extensions/ (user scope) #1691 instead of feat(install): experimental Copilot canvas extensions #1689). -- A broken link on the primary guide page signals incompleteness before a user reads a single line of code. The CHANGELOG mismatch is a durable audit trail error. Both are one-line fixes that belong in the same commit as the security fix.
  5. [DevX UX Expert] Emit a diagnostic warning when canvas install is silently skipped because the experimental flag is off -- at minimum a [!] line stating canvas bundles were found but not deployed because --experimental=canvas is not enabled. -- Silent no-op on flag-off is the single largest ergonomic gap for canvas authors. Without a signal, authors will open bugs or abandon the primitive after a failed first install that looks identical to a successful one.

Architecture

classDiagram
    direction LR
    class BaseIntegrator {
        +link_resolver UnifiedLinkResolver
        +check_collision(target_path, rel_path, managed_files, force) bool
        +normalize_managed_files(managed_files) set
        +is_content_identical_to_source(target, source) bool
        +sync_remove_files(project_root, managed_files, prefix, targets) dict
        +cleanup_empty_parents(paths, stop_at) None
        +validate_deploy_path(rel, project_root, targets) bool
    }
    class CanvasIntegrator {
        +find_canvas_bundles(package_path) list
        +integrate_canvases_for_target(target, package_info, project_root) IntegrationResult
        +sync_for_target(target, apm_package, project_root, managed_files) dict
        -_deploy_bundle(bundle, extensions_dir, project_root) str
        -_plan_bundle_files(bundle, canvas_root, project_root) list
        -_validate_canvas_name(name) None
        -_copilot_home_is_nondefault() bool
        -_warn(diagnostics, message, package_name) None
    }
    class IntegrationResult {
        +files_integrated int
        +files_updated int
        +files_skipped int
        +target_paths list
        +links_resolved int
        +files_adopted int
    }
    class IntegratorBundle {
        +prompt BaseIntegrator
        +agent BaseIntegrator
        +skill BaseIntegrator
        +instruction BaseIntegrator
        +command BaseIntegrator
        +hook BaseIntegrator
        +canvas BaseIntegrator or None
    }
    class TargetProfile {
        +name str
        +root_dir str
        +primitives dict
        +user_supported bool
        +for_scope(user_scope) TargetProfile
        +supports(primitive) bool
    }
    class PrimitiveMapping {
        +subdir str
        +extension str
        +format_id str
        +deploy_root str
    }
    class PrimitiveDispatch {
        +integrator_class type
        +integrate_method str
        +sync_method str
        +counter_key str
    }
    BaseIntegrator <|-- CanvasIntegrator : extends
    CanvasIntegrator ..> IntegrationResult : produces
    CanvasIntegrator ..> TargetProfile : reads
    IntegratorBundle o-- CanvasIntegrator : canvas optional
    TargetProfile *-- PrimitiveMapping : primitives
    PrimitiveDispatch ..> CanvasIntegrator : instantiates
    class CanvasIntegrator:::touched
    class IntegratorBundle:::touched
    classDef touched fill:#fff3b0,stroke:#d47600
Loading
flowchart TD
    A["apm install --trust-canvas-extensions\ncommands/install.py"] --> B["integrate_package_primitives\ninstall/services.py"]
    B --> C{"integrators.canvas\nis None?"}
    C -->|"None: legacy sites"| SKIP["no-op return"]
    C -->|"CanvasIntegrator present"| D["integrate_canvases_for_target\ncanvas_integrator.py"]
    D --> E{"is_enabled('canvas')\ncore/experimental.py"}
    E -->|"flag off"| SKIP
    E -->|"flag on"| F{"target.name == 'copilot'\nand mapping present?"}
    F -->|"no"| SKIP
    F -->|"yes"| G["find_canvas_bundles\n.apm/extensions/*/extension.mjs"]
    G --> H{"scope == USER?"}
    H -->|"user + nondefault COPILOT_HOME"| W1["warn: refuse nondefault COPILOT_HOME"]
    H -->|"user + is_first_party"| W2["warn: refuse first-party user-scope"]
    W1 --> SKIP
    W2 --> SKIP
    H -->|"project scope or dep user-scope"| I{"needs_trust = is_user or not is_first_party\ntrust_canvas?"}
    I -->|"needs_trust and not trust_canvas"| W3["_emit_trust_block\ndiagnostics.warn bundles blocked"]
    W3 --> SKIP
    I -->|"trust satisfied"| J["for bundle in bundles\n_deploy_bundle()"]
    J --> K["_validate_canvas_name\nregex + reserved Windows device names"]
    K -->|"invalid"| SKIP2["return 'skipped'"]
    K -->|"valid"| L["_plan_bundle_files\nbundle.rglob + ensure_path_within"]
    L -->|"PathTraversalError"| SKIP2
    L -->|"planned triples"| M{"non-file exists at dest?"}
    M -->|"yes"| SKIP2
    M -->|"no"| N{"unmanaged collision?\nnot in managed_files and not force"}
    N -->|"yes"| SKIP2
    N -->|"no"| O{"all files byte-identical?\nis_content_identical_to_source"}
    O -->|"yes"| ADOPT["return 'adopted'\ntarget_paths populated"]
    O -->|"no"| COPY["per-file: dest.parent.mkdir\nTOCTOU symlink check\nshutil.copyfile"]
    COPY -->|"symlink detected mid-loop"| SKIP2
    COPY -->|"all files written"| INT["return 'integrated'"]
    INT --> RESULT["IntegrationResult\ncounter_key='canvases'"]
    ADOPT --> RESULT
    SKIP2 --> RESULT
Loading
sequenceDiagram
    participant User
    participant CLI as commands/install.py
    participant Services as install/services.py
    participant Canvas as CanvasIntegrator
    participant Exp as core/experimental.py
    participant FS as Filesystem

    User->>CLI: apm install --trust-canvas-extensions
    CLI->>Services: integrate_package_primitives(trust_canvas=True, is_first_party=False)
    Services->>Canvas: integrate_canvases_for_target(target, trust_canvas=True, is_first_party=False)
    Canvas->>Exp: is_enabled("canvas")
    Exp-->>Canvas: True
    Canvas->>FS: find_canvas_bundles(.apm/extensions/)
    FS-->>Canvas: [dep_bundle]
    Note over Canvas: needs_trust = True (dep, not first-party)<br/>trust_canvas = True -- gate passes
    Canvas->>Canvas: _deploy_bundle(dep_bundle, extensions_dir)
    Canvas->>Canvas: _plan_bundle_files() -> [(src, dest, rel)]
    Canvas->>FS: shutil.copyfile src->dest per file
    FS-->>Canvas: files written
    Canvas-->>Services: IntegrationResult(files_integrated=1)
    Services-->>CLI: canvases count recorded (per-package log)
    CLI-->>User: canvas extension deployed to .github/extensions/
Loading

Recommendation

The canvas primitive is architecturally sound and strategically important, but it cannot merge with a confirmed trust gate bypass on macOS and Windows. The required rework is minimal: one line in canvas_integrator.py:91 (seg.lower() != 'extensions') plus a matching guard in integrate_local_bundle, verified against a capital-E Extensions/ fixture tarball. Once that fix lands, the dead link on canvas.md:25 and the CHANGELOG PR number mismatch should be corrected in the same commit to prevent a broken day-one doc experience. After those three changes, the PR is ready to merge. All remaining panel findings -- integration test gap for the deploy pipeline, _canvas_deploy_prefixes coverage, silent flag-off skip, help text length, primitive table omission in what-is-apm.md -- are tracked as post-merge follow-ups and are not merge blockers given the feature's explicit experimental gating.


Full per-persona findings

Python Architect

  • [recommended] canvas delta is never accumulated into ctx.total_canvases_integrated; finalize phase emits no verbose canvas count at src/apm_cli/install/phases/integrate.py:594
    phases/integrate.py accumulates total_hooks_integrated, total_commands_integrated, and others from per-package deltas, but the equivalent accumulation for 'canvases' is absent; neither the dep loop nor the root-project block calls deltas.get('canvases', 0), and ctx.total_canvases_integrated does not exist in context.py; finalize.py emits no verbose canvas count line.
    Suggested: Add total_canvases_integrated: int = 0 to InstallContext (context.py). Accumulate deltas.get('canvases', 0) at integrate.py:594 and :609. Write back at :625. Add a verbose branch in finalize.py alongside hooks and commands messages.

  • [recommended] _deploy_bundle TOCTOU guard fires mid-copy loop, leaving partial bundle on disk while reporting 'skipped' at src/apm_cli/integration/canvas_integrator.py:399
    The docstring states "the bundle is treated as a unit ... so a half-new/half-old executable extension is never produced." However, the TOCTOU symlink check (line 404) fires inside the per-file copy loop. If file N-1 has been written via shutil.copyfile and appended to target_paths, and file N then triggers the symlink guard, the function returns 'skipped' but those N-1 paths ARE in target_paths and enter the lockfile's deployed_files. The bundle counter says skipped while some files are deployed and lockfile-tracked.
    Suggested: Track files_written inside the copy loop. On symlink detection, unlink each path in files_written before returning 'skipped'. Alternatively, copy to a temp directory inside extensions_dir and do an atomic rename of the whole canvas_root at the end.

  • [recommended] Dispatch table key 'canvas' is singular; all other primitive keys are plural at src/apm_cli/integration/dispatch.py:72
    get_dispatch_table() returns keys: 'prompts', 'agents', 'commands', 'instructions', 'hooks', 'skills' (all plural) and 'canvas' (singular). The singular outlier creates a latent naming hazard for any code that iterates or generates the primitive name list.
    Suggested: Rename dispatch key from 'canvas' to 'canvases' and update all callers: target.primitives dict in targets.py, _INTEGRATOR_KWARGS in services.py, string comparisons in services.py lines 161 and 377.

  • [nit] _plan_bundle_files passes empty string instead of package_name to _warn on PathTraversalError at src/apm_cli/integration/canvas_integrator.py:447
    Suggested: Replace '' with package_name in the _warn call.

  • [nit] _copilot_home_is_nondefault imports os inside the method body; os is always available at src/apm_cli/integration/canvas_integrator.py:491
    Suggested: Move 'import os' to the top-level imports block.

  • [nit] _deploy_bundle lacks a return type annotation; the string-literal protocol is implicit at src/apm_cli/integration/canvas_integrator.py:310
    Suggested: Add 'from typing import Literal' and annotate as -> Literal['integrated', 'adopted', 'skipped'].

CLI Logging Expert

  • [recommended] install.py --trust-canvas-extensions help text is 290 chars and wraps to 7 lines in apm install --help at src/apm_cli/commands/install.py
    Click renders --help at 80 cols; the --trust-canvas-extensions option column consumes ~32 chars, leaving ~48 chars per help line. At 290 chars the text wraps to 7 lines, dwarfing every other flag including the peer --trust-transitive-mcp (89 chars, 2 lines). The pack.py sibling option gets this right at 70 chars.
    Suggested: Replace the 290-char help with: '[experimental] Deploy canvas extensions from dependencies. Canvas extensions are executable Node code and are blocked by default. Requires the canvas experimental feature.' (~175 chars, 4 lines).

  • [recommended] pack.py canvas_blocked warning duplicates a 67-char prefix across two if/else branches at src/apm_cli/commands/pack.py
    Both branches open with 'Blocked {N} canvas extension file(s): canvas extensions are ...' before diverging. A future edit to the shared prefix must be applied twice.
    Suggested: Extract to a single logger.warning call with a conditional suffix string.

  • [recommended] audit.py _render_canvas_note emits [i] symbol on every list continuation line at src/apm_cli/commands/audit.py
    logger.info(f' {root}', symbol='info') produces '[i] path/to/ext' for each item. With a header already bearing [i], every sub-item repeating the symbol is visual noise. Additionally, symbol='info' is the default for logger.info() so the explicit kwarg is redundant.
    Suggested: Replace logger.info(f' {root}', symbol='info') with logger.tree_item(f' {root}') or logger.verbose_detail(f' {root}') for sub-items.

  • [recommended] _emit_integration_hints fires canvas reload hint on adopt-only reinstalls, producing misleading guidance at src/apm_cli/install/services.py
    The condition is (info['files'] > 0 or info['adopted'] > 0). When info['files'] == 0 and info['adopted'] > 0, no new canvas code was deployed -- the canvas was already present and is already loaded in any active session. Emitting the reload hint on a pure-adopt run misleads the user into an unnecessary session restart.
    Suggested: Change condition to: if prim_name == 'canvas' and info['files'] > 0 -- emit reload hint only when at least one canvas file was freshly written.

  • [nit] _emit_trust_block interpolates CANVAS_MARKER ('extension.mjs') into user-facing prose at src/apm_cli/integration/canvas_integrator.py
    The diagnostic message reads 'canvas extensions are executable extension.mjs code'. The marker filename is an implementation detail. The pack.py warning correctly uses 'executable code' without the filename.
    Suggested: Replace f'executable {CANVAS_MARKER} code' with 'executable Node.js code' in the diagnostics.warn() call.

  • [nit] audit.py _render_canvas_note uses extension(s) plural hack instead of explicit singular/plural at src/apm_cli/commands/audit.py
    Suggested: noun = 'extension' if len(bundles) == 1 else 'extensions'; use f'{len(bundles)} executable canvas {noun} deployed (experimental, trust-gated):'.

DevX UX Expert

  • [recommended] Silent skip when feature flag is off gives canvas authors zero install signal at src/apm_cli/integration/canvas_integrator.py
    When is_enabled('canvas') is False the integrator returns an empty IntegrationResult with no warning, no note, and no skip-count contribution. A developer who authors .apm/extensions/my-canvas/extension.mjs and runs 'apm install --target copilot' without the experimental flag sees the install succeed with 0 canvases and no explanation. Compare: the trust-gate path correctly emits a named diagnostic with the opt-in flag.
    Suggested: After the bundles-empty check and before the is_enabled guard return, emit a once-per-install diagnostics.info noting the count of found-but-skipped bundles and the enable command. Suppress if bundles is empty.

  • [recommended] 'canvas' flag missing from docs/src/content/docs/reference/cli/experimental.md Available flags table
    The 'canvas' flag is registered in experimental.py and appears in the long-form reference page, but is absent from the CLI command reference table. When a user runs 'apm experimental list' they see 'canvas'; when they consult the CLI doc they find nothing.
    Suggested: Add a row to the Available flags table for 'canvas' with a one-line description and 'apm install --target copilot' as the usage example.

  • [recommended] Trust scope asymmetry between --trust-canvas-extensions and --trust-transitive-mcp is undocumented in flag help at src/apm_cli/commands/install.py
    --trust-transitive-mcp gates only transitive (indirect) dependencies; direct dependencies are implicitly trusted. --trust-canvas-extensions gates ALL dependency canvases including direct dependencies. A developer who adds owner/canvas-pkg directly to apm.yml will be surprised when their explicitly-declared dependency's canvas is blocked.
    Suggested: Revise help to: 'Deploy executable canvas extensions shipped by dependencies, including directly declared ones. Unlike --trust-transitive-mcp, this flag is required even for direct dependencies. First-party (root package) canvases deploy without it. Requires the canvas experimental feature.'

  • [nit] --trust-canvas-extensions help text is four sentences vs --trust-transitive-mcp single line at src/apm_cli/commands/install.py

  • [nit] Session reload hint says 'restart' which is ambiguous; docs use 'exit and relaunch' at src/apm_cli/install/services.py
    Suggested: Change to: '|-- reload the Copilot session (/clear) or exit and relaunch Copilot CLI to load the canvas'

  • [nit] apm audit canvas note does not disclose that canvas drift is not yet checked at src/apm_cli/commands/audit.py
    Suggested: After the bundle list, append a note that canvas drift detection is not yet implemented.

Supply Chain Security Expert

  • [blocking] Case-insensitive FS trust gate bypass via mixed-case Extensions/ directory in offline bundles at src/apm_cli/integration/canvas_integrator.py:91
    is_canvas_bundle_path() uses a literal string comparison: seg != 'extensions'. On macOS (HFS+) and Windows (NTFS), 'Extensions' and 'extensions' resolve to the same filesystem directory. A bundle crafted with path 'Extensions/myext/extension.mjs' causes is_canvas_bundle_path() to return False, so the file bypasses the trust filter and the Copilot-only target guard, deploying to all targets' default roots without operator consent. This is a direct bypass of the primary trust gate for executable Node.js code on the two dominant desktop platforms.
    Suggested: In is_canvas_bundle_path(), change 'if seg != "extensions":' to 'if seg.lower() != "extensions":'. In integrate_local_bundle() in services.py, change the extensions guard comparison similarly. Consider rejecting paths where 'extensions' segment is not exactly lowercase on case-sensitive systems.

  • [recommended] TOCTOU window between dest.is_symlink() check and shutil.copyfile() in _deploy_bundle at src/apm_cli/integration/canvas_integrator.py:404
    Between the symlink check and the copyfile call, an attacker with concurrent write access to the deploy directory can insert a symlink, causing shutil.copyfile to write content to the symlink's target outside canvas_root.
    Suggested: Replace the is_symlink()+copyfile() pair with an atomic open using os.open(dest, os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW, 0o666) and write the source bytes to the returned fd.

  • [recommended] Bare except in _canvas_extension_names silently suppresses all errors, blinding authors to executable content in their packages at src/apm_cli/models/validation.py:156
    If the import fails or find_canvas_bundles raises an OSError, the function returns an empty list. The caller does not set has_primitives via canvas_names and does not emit the executable-code warning. Authors running 'apm validate' would see no warning about the gated executable code.
    Suggested: Replace 'except Exception: return []' with 'except Exception as exc: import logging; logging.getLogger(name).debug("Canvas scan failed: %s", exc); return []'.

  • [nit] Partial bundle orphan on mid-loop symlink guard: written files not recorded in target_paths, leaving untracked executable assets at src/apm_cli/integration/canvas_integrator.py:399
    Suggested: Before the per-file loop, snapshot the set of files about to be written. On any mid-loop bail-out, delete already-written files from that bundle (rollback).

  • [nit] _copilot_home_is_nondefault uses resolve(strict=False) without a comment explaining the intentional fail-closed behavior at src/apm_cli/integration/canvas_integrator.py:496
    Suggested: Add a comment: '# strict=False is intentional: a non-existent or unresolvable COPILOT_HOME compares != default, so we return True (nondefault) and block global canvas deploy. Fail-closed behavior.'

OSS Growth Hacker

  • [recommended] The create-canvas to apm install end-to-end workflow is invoked but never demonstrated in docs
    canvas.md says 'Canvases are typically produced by the create-canvas skill' but this is the single reference to create-canvas across the entire docs corpus. There is no link to the skill, no example extension.mjs, and no runnable sequence. This is the primary conversion story for the feature.
    Suggested: Add a 'Full workflow' section to canvas.md with a minimal extension.mjs snippet, the create-canvas skill link, and the exact four-command sequence: scaffold, enable flag, install, reload Copilot.

  • [recommended] what-is-apm.md primitive table omits canvas, hiding the capability from new users
    docs/src/content/docs/concepts/what-is-apm.md lists eight rows: instructions, skills, prompts, agents, hooks, commands, plugins, MCP servers. Canvas is absent. This page is the canonical "What APM manages" reference.
    Suggested: Add a 'Canvas extensions (experimental, Copilot-only)' row to the what-is-apm.md primitive table with a one-line description and a link to integrations/canvas.md.

  • [recommended] No active discoverability path for installed canvases creates a silent failure mode for early adopters
    canvas.md states 'Canvases are not yet shown by apm list/apm compile' but does not tell the user how to verify a canvas deployed successfully. Early adopters who encounter issues have no self-service diagnostic path documented.
    Suggested: Add a 'Verify deployment' subsection to canvas.md listing three steps: check ls .github/extensions/, run apm audit to see the canvas note, reload the Copilot session.

  • [recommended] CHANGELOG entry is maintainer-facing; the feature milestone has no shareable narrative hook
    The CHANGELOG reads as implementation detail. This is the first time APM can distribute executable artifacts -- a real positioning moment. The entry should have a user-benefit headline sentence.
    Suggested: Prepend a user-benefit headline: 'APM can now distribute GitHub Copilot CLI canvas extensions: author a canvas with create-canvas, ship it in an APM package, and consumers get it in their Copilot session with apm install and zero manual file copying.'

  • [nit] Double-experimental signaling in canvas.md discourages early adopters most likely to become advocates (sidebar badge AND caution callout)

  • [nit] Community canvas contribution path is undocumented; producer ramp ends at first-party use

Auth Expert -- inactive

No auth surface touched. trust_canvas flag is a local-deployment feature flag with no bearing on authentication, credential resolution, or network authorization. Auth-expert activation was a false positive triggered by pipeline.py membership in the changed-file set.

Doc Writer

  • [recommended] create-canvas skill reference is a dead link to a non-existent artifact at docs/src/content/docs/integrations/canvas.md:25
    A search across all of src/, packages/, and .apm/ finds no skill named create-canvas. Under PROSE Safety Boundaries, planned features must use the :::note[Planned] callout or not be mentioned at all.
    Suggested: Remove the sentence, or replace it with: ':::note[Planned] A create-canvas skill for scaffolding canvas bundles is planned but not yet available. :::'

  • [recommended] Silent no-op when canvas flag is off is not disclosed; users will be confused when extension does not deploy at docs/src/content/docs/integrations/canvas.md
    canvas.md says 'With the flag off, .apm/extensions/ is ignored entirely.' but omits that the ignore is completely silent: no warning, no note, no skip-count. The trust-blocked case correctly emits a diagnostic; the feature-gate should disclose the silence.
    Suggested: Add after the quoted sentence: 'No warning or diagnostic is emitted; canvas content is silently skipped. If an installed package ships a canvas that is not appearing in your Copilot session, verify the flag is on with apm experimental list.'

  • [recommended] canvas.md limitations claim 'deployed at install only' but apm audit surfaces deployed canvas bundles -- inaccurate at docs/src/content/docs/integrations/canvas.md:137
    CHANGELOG line 23 and the source confirm: audit.py _render_canvas_note() reads apm.lock.yaml and emits an informational list of deployed canvas bundles in text format. The phrase 'deployed at install only' misrepresents the current state.
    Suggested: Replace the 'No compile/list surfacing yet' bullet with: 'Not shown by apm list or apm compile. apm audit (text format) lists deployed canvas bundles recorded in the lockfile; this is the current discovery path until list/compile support ships.'

  • [recommended] audit.md not updated despite apm audit gaining canvas-bundle surfacing in this PR at docs/src/content/docs/reference/cli/audit.md
    _render_canvas_note() is called in the text-format path of audit.py unconditionally. The audit.md reference page was not touched in this PR.
    Suggested: Add a bullet to the Behavior section: 'Canvas note. When canvas experimental flag is on and canvas bundles are recorded in the project lockfile, the text-format output includes an informational list of deployed canvas bundle roots. Not shown in --format json, sarif, or markdown.'

  • [nit] CHANGELOG second canvas entry attributes global install to Experimental canvas: support --global install to ~/.copilot/extensions/ (user scope) #1691 but the code ships in this PR (feat(install): experimental Copilot canvas extensions #1689) at CHANGELOG.md
    Suggested: Verify whether global canvas install is intended to land in feat(install): experimental Copilot canvas extensions #1689 or Experimental canvas: support --global install to ~/.copilot/extensions/ (user scope) #1691 and correct the attribution accordingly.

  • [nit] Session restart hint 'exit and relaunch' is ambiguous for VS Code Copilot surface at docs/src/content/docs/integrations/canvas.md
    Suggested: Clarify hint to distinguish terminal CLI vs VS Code Copilot Chat context.

  • [nit] Trust gate section re-explains content already in install.md flag table; cross-reference would be leaner at docs/src/content/docs/integrations/canvas.md

  • [nit] security.md canvas parenthetical makes the 'No arbitrary code execution' bullet harder to scan at docs/src/content/docs/enterprise/security.md
    Suggested: Break the canvas exception into a nested sub-bullet rather than an inline parenthetical.

Test Coverage Expert

  • [recommended] Canvas install pipeline is covered at unit tier only; install-pipeline floor requires integration-with-fixtures evidence
    TestLocalBundleCanvasTrust::test_canvas_deployed_with_trust_and_flag uses click.testing.CliRunner (unit tier) and lives in tests/unit/install/. Zero hits for canvas tests in tests/integration/. The APM install-pipeline surface floor requires integration-with-fixtures evidence.
    Proof (missing at integration-with-fixtures): tests/integration/test_canvas_install_e2e.py::test_canvas_deployed_via_cli_with_trust_and_flag -- proves: apm install --trust-canvas-extensions invoked as a real CLI process deploys canvas and exits 0 [secure-by-default, devx]

  • [recommended] _canvas_deploy_prefixes in drift.py is new code with zero test coverage; regression would emit false drift on every apm install at src/apm_cli/install/drift.py:589
    grep confirmed zero hits for _canvas_deploy_prefixes in tests/. All calls to diff_scratch_against_project in integration tests pass targets=[], so the canvas exclusion branch is never reached.
    Proof (missing): tests/integration/test_deps_models_phase3c.py::test_diff_scratch_canvas_paths_excluded_from_drift -- proves: apm install does not flag deployed canvas extension paths as orphaned drift [devx]

  • [recommended] Canvas file paths not asserted in apm.lock.yaml after install; lockfile round-trip for canvas uninstall is unverified at tests/unit/install/test_install_local_bundle.py
    test_canvas_deployed_with_trust_and_flag asserts the canvas file exists on disk but does not assert it appears under deployed_files in the written apm.lock.yaml. Without a lockfile assertion, the apm uninstall pruning path is not proven end-to-end.
    Proof (missing): tests/unit/install/test_install_local_bundle.py::TestLocalBundleCanvasTrust::test_canvas_paths_recorded_in_lockfile -- proves: apm install --trust-canvas-extensions writes canvas file paths to apm.lock.yaml so that apm uninstall can read and remove them [devx]

Performance Expert

  • [recommended] bundle.rglob("") in _plan_bundle_files has no file count ceiling at src/apm_cli/integration/canvas_integrator.py:432
    sorted(bundle.rglob("
    ")) materializes ALL files in the bundle as a list before any processing. A canvas bundle that accidentally includes node_modules or a vite build cache could contain 10,000+ entries. 20,000+ syscalls at ~1ms each on a network share = 20 seconds of blocking install time per bundle. There is no early-exit on count before or inside the loop.
    Suggested: After the sorted() call, check len(planned) > _MAX_CANVAS_FILES (e.g., 100) and return None (skip the bundle) if exceeded. Alternatively, enumerate with an early-exit inside the rglob loop.

  • [recommended] Adopt-detect pass reads full file content without a size pre-screen at src/apm_cli/integration/canvas_integrator.py:394
    is_content_identical_to_source reads the entire file into memory via an os.read loop; there is no stat().st_size comparison before the read. For a canvas bundle where extension.mjs is a minified webpack bundle (500KB to 2MB is common), a warm re-install with no changes reads 2*bundle_total_bytes per file. For a 10-file bundle at 500KB average: 10MB of read I/O per re-install just for adopt detection.
    Suggested: In is_content_identical_to_source (base_integrator.py), add a stat().st_size comparison as a pre-check before calling _read_bytes_no_follow. A size mismatch at ~50ns eliminates the full read for differing-size files.

  • [nit] canvas_root.resolve() called O(N_files) times in _plan_bundle_files via ensure_path_within at src/apm_cli/integration/canvas_integrator.py:442
    canvas_root is constant across all files in a bundle, so canvas_root.resolve() executes N times redundantly.
    Suggested: Resolve canvas_root once before the loop and pass the pre-resolved base into the containment check.

  • [nit] Drift exclusion filter is O(n*m) but m is capped at 1 in practice (canvas mapped to copilot target only); no action needed now at src/apm_cli/install/drift.py

This panel is advisory. It does not block merge. Re-apply the
panel-review label after addressing feedback to re-run.

Generated by PR Review Panel for issue #1689 · sonnet46 25.5M ·

@github-actions github-actions Bot removed the panel-review Trigger the apm-review-panel gh-aw workflow label Jun 9, 2026
Resolve 6 conflicts: CHANGELOG (keep canvas + ZIP/auth entries),
astro.config (keep canvas + hermes sidebar items), primitives table
(add Kiro column + canvas row), commands.md (merge Kiro/Hermes flags
with --trust-canvas-extensions), package-authoring (keep canvas +
sourceBase sections), test_unpacker (keep canvas trust + ZIP security
tests).

Refactor _deployed_path_entry to extract _try_dynamic_root helper,
eliminating duplicated loop and keeping services.py under 1000 LOC.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sergio Sisternes and others added 2 commits June 13, 2026 15:48
is_canvas_bundle_path() used literal 'extensions' comparison, allowing
a tarball with 'Extensions/myext/extension.mjs' (capital-E) to bypass
the trust gate on macOS HFS+ and Windows NTFS. Apply seg.lower() in
both canvas_integrator.py and the matching guard in services.py.

Also fix dead create-canvas link on canvas.md and correct CHANGELOG
global-install attribution from #1691 to #1689.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The refactor that extracted _try_dynamic_root from the inline loop
inadvertently added a relative_to pre-check on the fallback path,
preventing to_lockfile_path from running its own security validation.
Add strict=True parameter to skip the pre-check on fallback, matching
the original main branch behaviour where PathTraversalError propagates.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Experimental canvas: support --global install to ~/.copilot/extensions/ (user scope)

3 participants