diff --git a/CHANGELOG.md b/CHANGELOG.md index 52cf18db1..4b58da7ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 faithfully reflects on-disk content across Copilot, Claude, and Gemini targets (including OS-specific `windows`/`linux`/`osx` hook keys). (by @harshitlarl, closes #316, #1700) +- Experimental, Copilot-only `canvas` primitive: `.apm/extensions//extension.mjs` deploys to `.github/extensions//` on `apm install`/`apm pack`, gated by `apm experimental enable canvas` + `--trust-canvas-extensions` for dependencies. (by @sergio-sisternes-epam, #1689) +- Experimental canvas: `apm install --global --trust-canvas-extensions` deploys dependency canvases to `~/.copilot/extensions//`; `apm uninstall --global` prunes them. (by @sergio-sisternes-epam, #1689) +- Experimental canvas: package validation recognises canvas extensions (canvas-only packages valid, gated-executable warning); `apm audit` (text) lists deployed canvas bundles. (by @sergio-sisternes-epam, #1689) ### Changed diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 026c25283..08ca7f61b 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -225,6 +225,7 @@ export default defineConfig({ { label: 'GitHub Agentic Workflows', slug: 'integrations/gh-aw' }, { label: 'Microsoft 365 Copilot Cowork (Experimental)', slug: 'integrations/copilot-cowork' }, { label: 'GitHub Copilot App workflows (Experimental)', slug: 'integrations/copilot-app' }, + { label: 'Canvas extensions (Experimental)', slug: 'integrations/canvas' }, { label: 'Hermes Agent (Experimental)', slug: 'integrations/hermes' }, { label: 'AI runtime compatibility', slug: 'integrations/runtime-compatibility' }, { label: 'GitHub rulesets', slug: 'integrations/github-rulesets' }, diff --git a/docs/src/content/docs/concepts/primitives-and-targets.md b/docs/src/content/docs/concepts/primitives-and-targets.md index da9eba233..b74dfe25d 100644 --- a/docs/src/content/docs/concepts/primitives-and-targets.md +++ b/docs/src/content/docs/concepts/primitives-and-targets.md @@ -68,6 +68,14 @@ Model Context Protocol servers declared as dependencies. APM writes the per-harn - Source: `apm.yml` -> `dependencies.mcp:` - Deep dive: [MCP servers](/apm/consumer/install-mcp-servers/) +### Canvas extensions (experimental) + +GitHub Copilot CLI canvas extensions: a directory bundle whose entry file is `extension.mjs` (executable Node.js). Copilot-only. Behind the `canvas` experimental flag; dependency-provided canvases are blocked unless `--trust-canvas-extensions` is passed, because they are arbitrary executable code. Project scope deploys to `.github/extensions/`; `--global` deploys a dependency canvas to `~/.copilot/extensions/` (always requiring the trust flag). + +- Source: `.apm/extensions//extension.mjs` +- Deploys to: `.github/extensions//` (project) or `~/.copilot/extensions//` (`--global`) +- Deep dive: [Canvas extensions](/apm/integrations/canvas/) + ## Target catalogue Each target is identified by a slug used in `apm.yml`'s `targets:` field and on the `--target` flag. The output directory is where APM writes deployed primitives. The "agent-skills" and "copilot-cowork" targets exist in the registry but are not end-user runtimes; they are covered separately in the experimental reference. @@ -113,6 +121,7 @@ Rows are primitives, columns are harnesses. Cell legend: | commands | unsupported | native | compiled | unsupported | compiled | compiled | compiled | unsupported | | plugins | compiled | compiled | compiled | compiled | compiled | compiled | compiled | compiled | | MCP servers | native | native | native | native | native | native | native | native | +| canvas (experimental) | gated | unsupported | unsupported | unsupported | unsupported | unsupported | unsupported | unsupported | How to read a cell: @@ -122,6 +131,7 @@ How to read a cell: - `commands / copilot = unsupported` -- Copilot has no commands primitive; the same source `.prompt.md` reaches Copilot as a native prompt instead. - `plugins / *` -- APM unpacks the plugin at install time into the primitives in the rows above; routing then follows those rows. - `MCP servers / *` -- APM writes the harness's standard MCP config. Transitive MCP servers brought in by deep dependencies must be explicitly declared or trusted with `--trust-transitive-mcp` -- effectively `gated` for those, `native` for direct dependencies. +- `canvas / copilot = gated` -- requires the `canvas` experimental flag; a canvas shipped by a dependency is executable code, so it stays blocked until you pass `--trust-canvas-extensions`. First-party canvases in your own package deploy at project scope once the flag is on. With `--global`, a dependency canvas deploys to `~/.copilot/extensions/` and always requires the trust flag (first-party global install is not supported). Every other harness is `unsupported`: a canvas is a Copilot CLI construct only. ## Where compiled context files land diff --git a/docs/src/content/docs/enterprise/security.md b/docs/src/content/docs/enterprise/security.md index 377ce9fa5..96441c122 100644 --- a/docs/src/content/docs/enterprise/security.md +++ b/docs/src/content/docs/enterprise/security.md @@ -30,7 +30,7 @@ APM has no runtime footprint. Once `apm install` or `apm compile` completes, the - **No runtime component.** APM generates files then terminates. It does not run alongside your application. - **No network calls after install.** All network activity (git clone/fetch) occurs during dependency resolution. There are no callbacks, webhooks, or phone-home requests. -- **No arbitrary code execution.** APM does not execute scripts from packages, evaluate expressions in templates, or run downloaded code. +- **No arbitrary code execution.** APM does not execute scripts from packages, evaluate expressions in templates, or run downloaded code. (**Canvas exception:** the experimental `canvas` primitive deploys executable `extension.mjs` (Node.js) code to `.github/extensions/` or `~/.copilot/extensions/`; this surface is gated by both the `canvas` experimental flag and `--trust-canvas-extensions` for dependency-provided canvases. See [Canvas extensions](/apm/integrations/canvas/).) - **No access to application data.** APM never reads databases, API responses, application state, or user data. - **No persistent background processes.** APM does not install daemons, services, or scheduled tasks. - **No telemetry or data collection.** APM collects no usage data, analytics, or diagnostics. Nothing is transmitted to Microsoft or any third party. diff --git a/docs/src/content/docs/integrations/canvas.md b/docs/src/content/docs/integrations/canvas.md new file mode 100644 index 000000000..b426eee1b --- /dev/null +++ b/docs/src/content/docs/integrations/canvas.md @@ -0,0 +1,144 @@ +--- +title: "Canvas extensions (Experimental)" +description: "Ship GitHub Copilot CLI canvas extensions through APM packages (experimental, Copilot-only)." +sidebar: + order: 8 + badge: + text: Experimental + variant: caution +--- + +:::caution[Experimental] +This feature is behind the `canvas` experimental flag and is off by default. +It is **Copilot-only**, and the CLI surface may change. Enable it explicitly +before use. +::: + +A **canvas** is a GitHub Copilot CLI extension: a directory bundle whose entry +file is `extension.mjs` (executable Node.js), plus any sibling assets it needs. +Copilot CLI discovers canvases in immediate subdirectories of +`.github/extensions//` (project scope) and +`~/.copilot/extensions//` (user scope). APM lets a package carry a canvas +under `.apm/extensions//` and deploys it to the matching location at +install time so the canvas is available in your Copilot session. + +Canvases are typically produced by the Copilot CLI `create-canvas` skill +(scaffolds a working extension in `.github/extensions/`). This page covers +how to ship one through an APM package. + +## Enable the feature + +```bash +apm experimental enable canvas +``` + +Default APM behaviour never changes until the flag is enabled. With the flag +off, `.apm/extensions/` is ignored entirely. + +## Author a canvas + +Place the bundle under your package's `.apm/` directory. The marker file is +`extension.mjs`; a directory without it is ignored. + +``` +.apm/ + extensions/ + my-canvas/ + extension.mjs # required entry point (executable Node.js) + ui.js # optional sibling assets + styles.css +``` + +The `` segment becomes both the deploy directory and the extension id, so +it is validated strictly: `[A-Za-z0-9._-]+`, no leading or trailing dot, no +`..`, no path separators, and reserved device names are rejected. + +## Install + +```bash +apm install --target copilot +``` + +APM deploys the bundle verbatim to `.github/extensions/my-canvas/`. The deploy +is **atomic**: every file in the bundle is planned and validated first, and any +unmanaged local collision skips the whole bundle (use `apm install --force` to +overwrite) so you never end up with a half-updated executable extension. + +After a canvas deploys, start a new Copilot CLI session (exit and relaunch) -- +Copilot CLI discovers extensions at session start, so a freshly-deployed canvas +is not picked up mid-session. + +## Trust gate for dependency canvases + +A canvas shipped by a **dependency** is arbitrary executable Node.js code. APM +blocks dependency-provided canvases by default. To deploy them, opt in +explicitly: + +```bash +apm install --target copilot --trust-canvas-extensions +``` + +The trust gate is independent of the experimental flag: + +- The **experimental flag** decides whether the canvas primitive is processed at + all. It is a feature-availability gate, not a security gate. +- The **`--trust-canvas-extensions` flag** decides whether *dependency* + canvases may deploy. Your own first-party canvas (in the root package you are + installing from) deploys freely once the flag is on; only dependency-provided + canvases need the trust flag. + +When a dependency canvas is blocked, APM prints a diagnostic naming the package, +the canvas, the `extension.mjs` entry point, the deploy directory, and the +opt-in flag. The same gate is enforced on offline bundle install +(`apm install `) and on `apm unpack`, so a vendored bundle cannot +smuggle an executable canvas past trust. + +## Install globally (user scope) + +To make a canvas available in **every** Copilot session, install it globally so +it lands in `~/.copilot/extensions//`: + +```bash +apm install --global --trust-canvas-extensions +``` + +Global canvas install is intentionally limited in this experimental release: + +- **Dependency-provided only.** Only a canvas shipped by a package you install + (the `--global` flow always treats the canvas as dependency-provided) deploys + globally, so APM records it in the user lockfile and `apm uninstall --global` + can prune it. A first-party root `.apm/extensions/` canvas is **not** deployed + at user scope -- package it and install it as a dependency instead. +- **Trust is always required.** A global canvas has full-account blast radius, + so `--trust-canvas-extensions` is mandatory even though the project-scope + first-party path does not need it. +- **Default `~/.copilot` only.** If `$COPILOT_HOME` is set to a non-default + location, APM refuses the global canvas install rather than deploy to a path + Copilot will not scan. + +`apm uninstall --global ` removes the deployed +`~/.copilot/extensions//` files and prunes the empty directories. + +## Pack and uninstall + +`apm pack` preserves `.apm/extensions/` in the bundle, so a packed package keeps +its canvas. `apm uninstall` removes the deployed `.github/extensions//` +files and prunes the now-empty directories; uninstall is never gated by the +experimental flag, so a previously-installed canvas can always be removed. + +## Scope and limitations + +- **Copilot-only.** A canvas is a Copilot CLI construct. Other targets + (`--target claude`, `cursor`, etc.) never receive it. +- **Global install is dependency-only.** User-scope (`--global`) deployment to + `~/.copilot/extensions/` supports dependency-provided canvases (always + requiring `--trust-canvas-extensions`) and the default `~/.copilot` location + only; first-party root canvases deploy at project scope only. +- **No compile/list surfacing yet.** Canvases are not yet shown by + `apm list`/`apm compile`; they are deployed at install only. +- **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. + +See the [primitives and targets](/apm/concepts/primitives-and-targets/) matrix +for where the canvas primitive sits. diff --git a/docs/src/content/docs/reference/cli/install.md b/docs/src/content/docs/reference/cli/install.md index ae1242991..a6c9ec5da 100644 --- a/docs/src/content/docs/reference/cli/install.md +++ b/docs/src/content/docs/reference/cli/install.md @@ -61,6 +61,7 @@ With no arguments it installs everything from `apm.yml`. With one or more `PACKA | `--audit ` | (config/policy) | Run a content audit over the files this install deploys. `warn` records findings in the summary; `block` halts the install on critical findings. Overrides your `audit-on-install` config but cannot relax an org policy floor. Requires the `external-scanners` experimental flag. | | `--no-audit` | off | Disable the install-time audit for this invocation (equivalent to `--audit off`). Cannot relax an org policy `block` floor. | | `--trust-transitive-mcp` | off | Trust self-defined MCP servers shipped by transitive packages without re-declaring them in your `apm.yml`. | +| `--trust-canvas-extensions` | off | Trust executable canvas extensions (`extension.mjs`) shipped by dependencies. Required for dependency-provided canvases; first-party canvases deploy without it. Requires the `canvas` experimental flag. | | `--allow-insecure` | off | Permit direct `http://` (non-TLS) dependencies. | | `--allow-insecure-host HOSTNAME` | unset | Permit transitive `http://` dependencies from `HOSTNAME`. Repeatable. | diff --git a/docs/src/content/docs/reference/cli/pack.md b/docs/src/content/docs/reference/cli/pack.md index 1aa200812..29d6962ca 100644 --- a/docs/src/content/docs/reference/cli/pack.md +++ b/docs/src/content/docs/reference/cli/pack.md @@ -109,7 +109,7 @@ apm pack --archive --dry-run -v A Claude Code plugin directory under `--output`. Contains: - `plugin.json` -- schema-conformant manifest. Convention-dir keys are stripped because Claude Code auto-discovers them. -- Plugin-native subdirs populated from your `.apm/` content and from installed dependencies: `agents/`, `skills/`, `commands/`, `instructions/`, `hooks/`. +- Plugin-native subdirs populated from your `.apm/` content and from installed dependencies: `agents/`, `skills/`, `commands/`, `instructions/`, `hooks/`, `extensions/` (canvas extensions, when the `canvas` experimental flag is enabled). - A merged `hooks.json` when multiple sources contribute hooks. - `apm.lock.yaml` -- enriched copy with `pack:` metadata and a `bundle_files` map of per-file SHA-256 digests, used by `apm install` for install-time integrity verification. - `devDependencies` are excluded. diff --git a/docs/src/content/docs/reference/cli/unpack.md b/docs/src/content/docs/reference/cli/unpack.md index 287e73bee..17c8c94ef 100644 --- a/docs/src/content/docs/reference/cli/unpack.md +++ b/docs/src/content/docs/reference/cli/unpack.md @@ -31,6 +31,7 @@ Extraction is **additive-only**: only files listed in the bundle's lockfile are | `--skip-verify` | off | Skip the bundle completeness check against the bundle's `apm.lock.yaml`. Useful for partial bundles. | | `--dry-run` | off | List files that would be unpacked without writing anything. | | `--force` | off | Deploy despite critical hidden-character findings from the security scan. Use only after independent verification. | +| `--trust-canvas-extensions` | off | Trust executable canvas extensions (`extension.mjs`) in the bundle. Without this, canvas files are stripped during extraction. Requires the `canvas` experimental flag. | | `--verbose`, `-v` | off | Show per-file paths and full diagnostic context. | ## Examples diff --git a/docs/src/content/docs/reference/experimental.md b/docs/src/content/docs/reference/experimental.md index 24361daa3..a9c303a1f 100644 --- a/docs/src/content/docs/reference/experimental.md +++ b/docs/src/content/docs/reference/experimental.md @@ -175,6 +175,7 @@ apm experimental reset verbose-version | `marketplace-authoring`| Enable marketplace authoring commands (init, build, publish, etc.). | | `registries` | Enable REST-based APM package registries in `apm.yml`. | | `external-scanners` | Ingest third-party SARIF scanners into `apm audit` (`--external`, including SkillSpector LLM mode and allowlisted `--external-args`), the `external..{llm,args}` config keys, and the `security.audit.scanners` policy block. See [External scanners](../integrations/external-scanners/). | +| `canvas` | Ship Copilot CLI canvas extensions (`.apm/extensions//extension.mjs`) through APM packages. Dependency-provided canvases additionally require `--trust-canvas-extensions`. See [Canvas extensions](../integrations/canvas/). | New flags are proposed via [CONTRIBUTING.md](https://github.com/microsoft/apm/blob/main/CONTRIBUTING.md#how-to-add-an-experimental-feature-flag) and graduate to default when stable. See the contributor recipe for the full lifecycle. See also: [Cowork integration](../integrations/copilot-cowork/). diff --git a/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md index dd29cb1ba..0af81610c 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/commands.md +++ b/packages/apm-guide/.apm/skills/apm-usage/commands.md @@ -10,7 +10,7 @@ | Command | Purpose | Key flags | |---------|---------|-----------| -| `apm install [PKGS...]` | Install APM and MCP dependencies (supports APM packages, Claude skills (SKILL.md), and plugin collections (plugin.json)) | `--update` (deprecated; prefer `apm update`) refresh refs, `--refresh` re-fetch all deps from upstream and re-resolve all ref pins, `--force` overwrite (does NOT refresh refs; use `apm update` for that), `--frozen` CI-safe install that fails fast when `apm.lock.yaml` is missing or out of sync with `apm.yml` (mutually exclusive with `--update`; structural presence check only -- use `apm audit` for SHA integrity), `--dry-run`, `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated, e.g. `--target claude,cursor`; highest-priority entry in the resolution chain `--target` > apm.yml `targets:` > auto-detect; `--target all` deprecated, see `apm compile --all`; use `kiro` for Kiro IDE; use `copilot-cowork` with `--global` after `apm experimental enable copilot-cowork`; use `hermes` after `apm experimental enable hermes` to deploy skills + `AGENTS.md` and, at `--global`, MCP servers to `~/.hermes/config.yaml`), `--dev`, `-g` global (MCP deploys only to user-scope runtimes: Copilot CLI, Claude Code, Codex CLI, Gemini CLI, Kiro, Windsurf, JetBrains Copilot, and Hermes when enabled), `--trust-transitive-mcp`, `--parallel-downloads N`, `--allow-insecure`, `--allow-insecure-host HOSTNAME`, `--skill NAME` install named skill(s) from a skill collection (SKILL_BUNDLE or plugin manifest; repeatable; plugin manifests accept a leaf name or manifest path; persisted in apm.yml; `'*'` resets to all), `--legacy-skill-paths` restore per-client skill dirs, `--mcp NAME` add MCP entry (NAME goes through the same `--target` > `targets:` > auto-detect resolver as APM packages, so a project whitelisting `targets: [copilot]` will not write `.cursor/mcp.json` even if `.cursor/` exists; `apm install -g --mcp NAME` writes user-scope and bypasses the project-scope gate by design), `--transport`, `--url`, `--env KEY=VAL`, `--header KEY=VAL`, `--mcp-version`, `--registry URL` custom MCP registry, `--root DIR` redirect writes (`apm_modules/`, lockfile, `.gitignore`, integrated harness files) under DIR while `apm.yml`/`.apm/`/local deps resolve from `$PWD` (mirrors `pip install --target`; created if missing; not valid with `-g`/`--global`, which exits 2) | +| `apm install [PKGS...]` | Install APM and MCP dependencies (supports APM packages, Claude skills (SKILL.md), and plugin collections (plugin.json)) | `--update` (deprecated; prefer `apm update`) refresh refs, `--refresh` re-fetch all deps from upstream and re-resolve all ref pins, `--force` overwrite (does NOT refresh refs; use `apm update` for that), `--frozen` CI-safe install that fails fast when `apm.lock.yaml` is missing or out of sync with `apm.yml` (mutually exclusive with `--update`; structural presence check only -- use `apm audit` for SHA integrity), `--dry-run`, `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated, e.g. `--target claude,cursor`; highest-priority entry in the resolution chain `--target` > apm.yml `targets:` > auto-detect; `--target all` deprecated, see `apm compile --all`; use `kiro` for Kiro IDE; use `copilot-cowork` with `--global` after `apm experimental enable copilot-cowork`; use `hermes` after `apm experimental enable hermes` to deploy skills + `AGENTS.md` and, at `--global`, MCP servers to `~/.hermes/config.yaml`), `--dev`, `-g` global (MCP deploys only to user-scope runtimes: Copilot CLI, Claude Code, Codex CLI, Gemini CLI, Kiro, Windsurf, JetBrains Copilot, and Hermes when enabled), `--trust-transitive-mcp`, `--trust-canvas-extensions` (deploy executable canvas extensions from dependencies; requires `apm experimental enable canvas`; first-party canvases deploy without it), `--parallel-downloads N`, `--allow-insecure`, `--allow-insecure-host HOSTNAME`, `--skill NAME` install named skill(s) from a skill collection (SKILL_BUNDLE or plugin manifest; repeatable; plugin manifests accept a leaf name or manifest path; persisted in apm.yml; `'*'` resets to all), `--legacy-skill-paths` restore per-client skill dirs, `--mcp NAME` add MCP entry (NAME goes through the same `--target` > `targets:` > auto-detect resolver as APM packages, so a project whitelisting `targets: [copilot]` will not write `.cursor/mcp.json` even if `.cursor/` exists; `apm install -g --mcp NAME` writes user-scope and bypasses the project-scope gate by design), `--transport`, `--url`, `--env KEY=VAL`, `--header KEY=VAL`, `--mcp-version`, `--registry URL` custom MCP registry, `--root DIR` redirect writes (`apm_modules/`, lockfile, `.gitignore`, integrated harness files) under DIR while `apm.yml`/`.apm/`/local deps resolve from `$PWD` (mirrors `pip install --target`; created if missing; not valid with `-g`/`--global`, which exits 2) | | `apm targets` | Show resolved deployment targets for the current project (Click group; reads filesystem signals; works with or without `apm.yml`) | `--all` also include the `agent-skills` meta-target (only meaningful with `--json`), `--json` machine-readable output. No provenance line is printed (the table is the provenance). | | `apm uninstall PKGS...` | Remove packages (accepts `owner/repo` or `name@marketplace`) | `--dry-run`, `-g` global | | `apm prune` | Remove orphaned packages | `--dry-run` | diff --git a/packages/apm-guide/.apm/skills/apm-usage/governance.md b/packages/apm-guide/.apm/skills/apm-usage/governance.md index 21942d88d..adab538db 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/governance.md +++ b/packages/apm-guide/.apm/skills/apm-usage/governance.md @@ -150,6 +150,32 @@ Deployed executables are placed on Claude Code's `PATH` and invoked without further confirmation, so use this field to opt out in environments where plugin executables are not trusted by default. +## Canvas extension trust (experimental) + +Behind the `canvas` experimental flag, a package may ship a Copilot CLI canvas +extension under `.apm/extensions//extension.mjs` (executable Node.js). +Because a canvas from a dependency is arbitrary executable code, APM **blocks +dependency-provided canvases by default**: the consumer must pass +`--trust-canvas-extensions` to deploy them. A first-party canvas in the root +package being installed deploys once the flag is on; dependency canvases always +require the trust flag. + +At **project scope** a canvas deploys to `.github/extensions//`. With +`--global`, a **dependency-provided** canvas deploys to +`~/.copilot/extensions//` so it is available in every Copilot session; +global install always requires `--trust-canvas-extensions` (full-account blast +radius), supports only the default `~/.copilot` location (a non-default +`$COPILOT_HOME` is refused), and does not deploy first-party root canvases +(package them as a dependency instead). `apm uninstall --global` prunes the +global canvas. + +The trust gate is enforced on every install path -- normal install, offline +bundle install (`apm install `), and `apm unpack` -- so a vendored +bundle cannot smuggle an executable canvas past trust. The flag is a +feature-availability toggle, not a security gate; the trust requirement holds +regardless of the flag. An enterprise policy field for canvas trust is a +deferred follow-up and is not part of this experimental release. + ## Local content governance The `includes:` field in `apm.yml` controls which local `.apm/` content the diff --git a/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md b/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md index 54171786f..59bf8b9e6 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md +++ b/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md @@ -370,6 +370,24 @@ target is present. Authoring rules: - Governance: a `bin_deploy` policy rule can deny deployment per package. See the [policy schema](../../../../../docs/src/content/docs/reference/policy-schema.md#bin_deploy). +## Canvas extensions (experimental, Copilot-only) + +Behind the `canvas` experimental flag (`apm experimental enable canvas`), a +package may ship a GitHub Copilot CLI canvas extension. Place a directory bundle +under `.apm/extensions//` with an `extension.mjs` entry file (executable +Node.js) plus any sibling assets; a directory without `extension.mjs` is ignored. + +On `apm install --target copilot`, APM deploys it verbatim to +`.github/extensions//`. The `` segment is validated strictly +(`[A-Za-z0-9._-]+`, no leading/trailing dot, no `..`, no separators, no reserved +names). It is **Copilot-only**. Dependency-provided canvases are executable code +and are blocked unless the consumer passes `--trust-canvas-extensions`; a +first-party canvas in the root package deploys once the flag is on. With +`--global`, a dependency canvas deploys to `~/.copilot/extensions//` +(always requiring the trust flag; default `~/.copilot` only; first-party root +canvases are project-scope only). `apm pack` preserves `.apm/extensions/`. See +the [canvas integration guide](../../../../../docs/src/content/docs/integrations/canvas.md). + ## Marketplace source bases Marketplace publishers can declare `marketplace.sourceBase` when package diff --git a/src/apm_cli/bundle/plugin_exporter.py b/src/apm_cli/bundle/plugin_exporter.py index 84588f49f..3c2e8b48c 100644 --- a/src/apm_cli/bundle/plugin_exporter.py +++ b/src/apm_cli/bundle/plugin_exporter.py @@ -108,6 +108,12 @@ def _collect_apm_components(apm_dir: Path) -> list[tuple[Path, str]]: # commands/ -> commands/ _collect_recursive(apm_dir / "commands", "commands", components) + # extensions/ -> extensions/ (canvas extensions, experimental Copilot-only). + # Preserved verbatim so an offline bundle can carry a canvas; the files are + # inert until the consumer enables the ``canvas`` experimental flag AND + # passes ``--trust-canvas-extensions`` at install time. + _collect_recursive(apm_dir / "extensions", "extensions", components) + return components @@ -118,7 +124,7 @@ def _collect_root_plugin_components(project_root: Path) -> list[tuple[Path, str] ``skills/``, etc. at the repo root) have their files picked up here. """ components: list[tuple[Path, str]] = [] - for dir_name in ("agents", "skills", "commands", "instructions"): + for dir_name in ("agents", "skills", "commands", "instructions", "extensions"): _collect_recursive(project_root / dir_name, dir_name, components) return components diff --git a/src/apm_cli/bundle/unpacker.py b/src/apm_cli/bundle/unpacker.py index 03a6391b8..4f823720e 100644 --- a/src/apm_cli/bundle/unpacker.py +++ b/src/apm_cli/bundle/unpacker.py @@ -30,6 +30,7 @@ class UnpackResult: skipped_count: int = 0 security_warnings: int = 0 security_critical: int = 0 + canvas_blocked: int = 0 pack_meta: dict = field(default_factory=dict) @@ -39,6 +40,7 @@ def unpack_bundle( skip_verify: bool = False, dry_run: bool = False, force: bool = False, + trust_canvas: bool = False, ) -> UnpackResult: """Extract and apply an APM bundle to a project directory. @@ -52,6 +54,10 @@ def unpack_bundle( skip_verify: If *True*, skip completeness verification against the lockfile. dry_run: If *True*, resolve the file list but write nothing to disk. force: If *True*, deploy even when critical hidden characters are found. + trust_canvas: If *True*, allow executable canvas extension files + (``.github/extensions//extension.mjs``) to be unpacked. + Defaults to *False* (fail closed) so a vendored bundle cannot + smuggle executable canvas code past the trust gate. Returns: :class:`UnpackResult` describing what was (or would be) extracted. @@ -163,6 +169,28 @@ def unpack_bundle( if dep_files: dep_file_map[dep_key] = dep_files + # Security + feature gate: canvas extensions are executable Node + # bundles (``extension.mjs``). ``apm unpack`` copies deployed files + # verbatim WITHOUT routing through ``CanvasIntegrator``, so neither + # the experimental feature flag nor its trust gate would otherwise + # apply. Require BOTH gates -- the ``canvas`` experimental flag ON + # (feature availability) AND ``trust_canvas`` (executable-code trust) + # -- before unpacking canvas paths. Fail closed: drop them when + # either gate is missing. + canvas_blocked = 0 + from ..core.experimental import is_enabled + from ..integration.canvas_integrator import is_canvas_bundle_path + + if not (is_enabled("canvas") and trust_canvas): + _blocked = {f for f in unique_files if is_canvas_bundle_path(f)} + if _blocked: + canvas_blocked = len(_blocked) + unique_files = [f for f in unique_files if f not in _blocked] + for _k in list(dep_file_map): + dep_file_map[_k] = [f for f in dep_file_map[_k] if f not in _blocked] + if not dep_file_map[_k]: + del dep_file_map[_k] + # 3. Verify completeness verified = True if not skip_verify: @@ -211,6 +239,7 @@ def unpack_bundle( dependency_files=dep_file_map, security_warnings=security_warnings, security_critical=security_critical, + canvas_blocked=canvas_blocked, pack_meta=pack_meta, ) @@ -254,6 +283,7 @@ def unpack_bundle( skipped_count=skipped, security_warnings=security_warnings, security_critical=security_critical, + canvas_blocked=canvas_blocked, pack_meta=pack_meta, ) finally: diff --git a/src/apm_cli/commands/audit.py b/src/apm_cli/commands/audit.py index 9eb33200c..6ca6ca91a 100644 --- a/src/apm_cli/commands/audit.py +++ b/src/apm_cli/commands/audit.py @@ -233,6 +233,50 @@ def _render_findings_table( ) +def _deployed_canvas_bundles(project_root: Path, package_filter: str | None) -> list[str]: + """Return sorted canvas bundle roots deployed per apm.lock.yaml. + + A canvas bundle is an executable Copilot extension (``extension.mjs``) + deployed under a client ``extensions/`` directory (``.github/extensions/`` + project scope, ``.copilot/extensions/`` user scope). Surfacing them lets an + audit reader see at a glance that executable extension code is installed, + even when the content scan finds no hidden characters. Returns bundle roots + such as ``.copilot/extensions/widget`` (one entry per bundle). + """ + from ..integration.canvas_integrator import is_canvas_bundle_path + + lock = LockFile.read(get_lockfile_path(project_root)) + if lock is None: + return [] + + roots: set[str] = set() + for dep_key, dep in lock.dependencies.items(): + if package_filter and dep_key != package_filter: + continue + for rel in dep.deployed_files: + norm = rel.replace("\\", "/").strip("/") + if not norm or not is_canvas_bundle_path(norm): + continue + parts = norm.split("/") + for idx, seg in enumerate(parts): + if seg == "extensions" and idx + 1 < len(parts): + roots.add("/".join(parts[: idx + 2])) + break + return sorted(roots) + + +def _render_canvas_note(project_root: Path, package_filter: str | None, logger) -> None: + """Emit an informational note listing deployed canvas extensions.""" + bundles = _deployed_canvas_bundles(project_root, package_filter) + if not bundles: + return + logger.info( + f"{len(bundles)} executable canvas extension(s) deployed (experimental, trust-gated):" + ) + for root in bundles: + logger.info(f" {root}", symbol="info") + + def _render_summary( findings_by_file: dict[str, list[ScanFinding]], files_scanned: int, @@ -916,6 +960,8 @@ def _audit_content_scan( if findings_by_file: _render_findings_table(findings_by_file, verbose=cfg.verbose) _render_summary(findings_by_file, files_scanned, logger) + if not file_path: + _render_canvas_note(cfg.project_root, package, logger) if drift_findings: from ..install.drift import render_drift_text diff --git a/src/apm_cli/commands/install.py b/src/apm_cli/commands/install.py index ef3a2a0d8..518848398 100644 --- a/src/apm_cli/commands/install.py +++ b/src/apm_cli/commands/install.py @@ -222,6 +222,7 @@ class InstallContext: protocol_pref: Any # ProtocolPreference allow_protocol_fallback: bool trust_transitive_mcp: bool + trust_canvas: bool no_policy: bool install_mode: Any # InstallMode packages: tuple # Original Click packages @@ -916,6 +917,11 @@ def _handle_mcp_install( is_flag=True, help="Trust self-defined MCP servers from transitive packages (skip re-declaration requirement)", ) +@click.option( + "--trust-canvas-extensions", + is_flag=True, + help="[experimental] Deploy canvas extensions provided by dependencies. Canvas extensions are executable Node code and are blocked by default; this flag opts in. With --global the canvas deploys to ~/.copilot/extensions and the flag is always required. Requires the 'canvas' experimental feature.", +) @click.option( "--parallel-downloads", type=int, @@ -1125,6 +1131,7 @@ def install( # noqa: PLR0913 frozen, verbose, trust_transitive_mcp, + trust_canvas_extensions, parallel_downloads, dev, target, @@ -1267,6 +1274,7 @@ def install( # noqa: PLR0913 alias=alias, logger=logger, legacy_skill_paths=legacy_skill_paths, + trust_canvas=trust_canvas_extensions, # Rejected-flag context for consolidated UsageError: rejected_flags={ "--update": update, @@ -1535,6 +1543,7 @@ def install( # noqa: PLR0913 protocol_pref=protocol_pref, allow_protocol_fallback=allow_protocol_fallback, trust_transitive_mcp=trust_transitive_mcp, + trust_canvas=trust_canvas_extensions, no_policy=no_policy, audit_override=audit_override, install_mode=InstallMode(only) if only else InstallMode.ALL, @@ -1787,6 +1796,7 @@ def _install_apm_packages(ctx, outcome): skill_subset=ctx.skill_subset, skill_subset_from_cli=ctx.skill_subset_from_cli, refresh=ctx.refresh, + trust_canvas=ctx.trust_canvas, ) apm_count = install_result.installed_count apm_diagnostics = install_result.diagnostics @@ -2041,6 +2051,7 @@ def _install_apm_dependencies( # noqa: PLR0913 plan_callback=None, refresh: bool = False, lockfile_only: bool = False, + trust_canvas: bool = False, ): """Thin wrapper -- builds an :class:`InstallRequest` and delegates to :class:`apm_cli.install.service.InstallService`. @@ -2081,5 +2092,6 @@ def _install_apm_dependencies( # noqa: PLR0913 plan_callback=plan_callback, refresh=refresh, lockfile_only=lockfile_only, + trust_canvas=trust_canvas, ) return InstallService().run(request) diff --git a/src/apm_cli/commands/pack.py b/src/apm_cli/commands/pack.py index c00d864d9..bd228c9c8 100644 --- a/src/apm_cli/commands/pack.py +++ b/src/apm_cli/commands/pack.py @@ -741,9 +741,17 @@ def _render_marketplace_catalog(logger, written: list[tuple[str | None, Path]]) default=False, help="Deploy despite critical hidden-character findings.", ) +@click.option( + "--trust-canvas-extensions", + is_flag=True, + default=False, + help="Deploy executable canvas extensions (.github/extensions/) from the bundle.", +) @click.option("--verbose", "-v", is_flag=True, help="Show detailed unpacking information") @click.pass_context -def unpack_cmd(ctx, bundle_path, output, skip_verify, dry_run, force, verbose): +def unpack_cmd( + ctx, bundle_path, output, skip_verify, dry_run, force, trust_canvas_extensions, verbose +): """Extract an APM bundle into the project.""" logger = CommandLogger("unpack", verbose=verbose, dry_run=dry_run) logger.warning( @@ -759,11 +767,30 @@ def unpack_cmd(ctx, bundle_path, output, skip_verify, dry_run, force, verbose): skip_verify=skip_verify, dry_run=dry_run, force=force, + trust_canvas=trust_canvas_extensions, ) # Surface bundle metadata and warn on target mismatch _log_bundle_meta(result, Path(output), logger) + if result.canvas_blocked > 0: + from apm_cli.core.experimental import is_enabled + + if is_enabled("canvas"): + logger.warning( + f"Blocked {result.canvas_blocked} canvas extension file(s): canvas " + "extensions are executable code and are not unpacked by default. " + "Re-run with '--trust-canvas-extensions' to deploy them to " + ".github/extensions/." + ) + else: + logger.warning( + f"Blocked {result.canvas_blocked} canvas extension file(s): canvas " + "extensions are an experimental feature and are disabled. Enable " + "them with 'apm experimental enable canvas' (then re-run with " + "'--trust-canvas-extensions' to deploy executable canvas code)." + ) + if dry_run: logger.dry_run_notice("No files written") if result.files: diff --git a/src/apm_cli/core/experimental.py b/src/apm_cli/core/experimental.py index 194df8e91..d1463c3cc 100644 --- a/src/apm_cli/core/experimental.py +++ b/src/apm_cli/core/experimental.py @@ -94,6 +94,18 @@ class ExperimentalFlag: default=False, hint=("Use registries: in apm.yml. See https://microsoft.github.io/apm/guides/registries/"), ), + "canvas": ExperimentalFlag( + name="canvas", + description="Ship Copilot CLI canvas extensions via .apm/extensions/ bundles.", + default=False, + hint=( + "Author a canvas under .apm/extensions//extension.mjs, then " + "'apm install' deploys it to .github/extensions/. Dependency-provided " + "canvases are executable and blocked unless you pass " + "'--trust-canvas-extensions'. See " + "https://microsoft.github.io/apm/integrations/canvas/" + ), + ), "external_scanners": ExperimentalFlag( name="external_scanners", description="External SARIF scanner ingestion + optional audit at install time.", diff --git a/src/apm_cli/install/context.py b/src/apm_cli/install/context.py index df1d4496a..7a3be18da 100644 --- a/src/apm_cli/install/context.py +++ b/src/apm_cli/install/context.py @@ -63,6 +63,10 @@ class InstallContext: verbose: bool = False refresh: bool = False dev: bool = False + # --trust-canvas-extensions: opt in to deploying dependency-provided + # canvas extensions (executable Node code). First-party (root project .apm/) + # canvases deploy without this; only dependency canvases are gated. + trust_canvas: bool = False only_packages: list[str] | None = None protocol_pref: Any = None # ProtocolPreference (NONE/SSH/HTTPS) for shorthand transport allow_protocol_fallback: bool | None = None # None => read APM_ALLOW_PROTOCOL_FALLBACK env diff --git a/src/apm_cli/install/drift.py b/src/apm_cli/install/drift.py index ccd01a568..c1a81bd67 100644 --- a/src/apm_cli/install/drift.py +++ b/src/apm_cli/install/drift.py @@ -603,6 +603,25 @@ def _inline_diff_for(scratch_path: Path, project_path: Path) -> str: return "" +def _canvas_deploy_prefixes(targets) -> set[str]: + """Return ``root/subdir/`` prefixes for every target carrying a canvas mapping. + + Used to exclude canvas extension deploy paths from drift comparison + (the replay deliberately does not re-integrate canvases). + """ + prefixes: set[str] = set() + for target in targets or []: + mapping = getattr(target, "primitives", {}).get("canvas") + if mapping is None: + continue + effective_root = mapping.deploy_root or target.root_dir + if mapping.subdir: + prefixes.add(f"{effective_root}/{mapping.subdir}/") + else: + prefixes.add(f"{effective_root}/") + return prefixes + + def diff_scratch_against_project( scratch_root: Path, project_root: Path, @@ -628,6 +647,21 @@ def diff_scratch_against_project( project_files = _walk_managed(project_root, governed) tracked = _collect_tracked_files(lockfile) + # Canvas extensions are executable bundles that the drift replay does + # not re-integrate (their integrator is intentionally omitted from the + # replay bundle). Exclude their deploy prefixes from BOTH trees so a + # deployed canvas is never mis-reported as orphaned/unintegrated. Full + # canvas drift detection is a deferred follow-up. + _canvas_prefixes = _canvas_deploy_prefixes(targets) + if _canvas_prefixes: + + def _is_canvas(rel: str) -> bool: + norm = rel.replace("\\", "/") + return any(norm.startswith(p) for p in _canvas_prefixes) + + scratch_files = {r: p for r, p in scratch_files.items() if not _is_canvas(r)} + project_files = {r: p for r, p in project_files.items() if not _is_canvas(r)} + findings: list[DriftFinding] = [] for rel, scratch_path in sorted(scratch_files.items()): diff --git a/src/apm_cli/install/local_bundle_handler.py b/src/apm_cli/install/local_bundle_handler.py index ae5d42a49..627068329 100644 --- a/src/apm_cli/install/local_bundle_handler.py +++ b/src/apm_cli/install/local_bundle_handler.py @@ -44,6 +44,7 @@ def install_local_bundle( logger, legacy_skill_paths: bool = False, rejected_flags: dict[str, object], + trust_canvas: bool = False, ) -> None: """Deploy a local bundle into project / user scope. @@ -137,6 +138,7 @@ def install_local_bundle( logger=logger, scope=scope, alias=alias, + trust_canvas=trust_canvas, ) deployed = result.get("deployed_files", []) diff --git a/src/apm_cli/install/phases/integrate.py b/src/apm_cli/install/phases/integrate.py index f29ff9ef1..b36571676 100644 --- a/src/apm_cli/install/phases/integrate.py +++ b/src/apm_cli/install/phases/integrate.py @@ -364,6 +364,7 @@ def _integrate_root_project( "instructions", "commands", "hooks", + "canvases", ) ) if _local_total > 0 and logger: @@ -378,6 +379,7 @@ def _integrate_root_project( "instructions": _root_result["instructions"], "commands": _root_result["commands"], "hooks": _root_result["hooks"], + "canvases": _root_result.get("canvases", 0), "links_resolved": _root_result["links_resolved"], } except Exception as e: diff --git a/src/apm_cli/install/phases/local_content.py b/src/apm_cli/install/phases/local_content.py index 2fe690037..49753b55b 100644 --- a/src/apm_cli/install/phases/local_content.py +++ b/src/apm_cli/install/phases/local_content.py @@ -74,6 +74,7 @@ def _has_local_apm_content(project_root): "prompts", "hooks", "commands", + "extensions", ) for subdir_name in _PRIMITIVE_DIRS: subdir = apm_dir / subdir_name diff --git a/src/apm_cli/install/phases/targets.py b/src/apm_cli/install/phases/targets.py index 6af9ab8e0..41003559f 100644 --- a/src/apm_cli/install/phases/targets.py +++ b/src/apm_cli/install/phases/targets.py @@ -448,6 +448,7 @@ def run(ctx: InstallContext) -> None: detect_target, ) from apm_cli.integration import AgentIntegrator, PromptIntegrator + from apm_cli.integration.canvas_integrator import CanvasIntegrator from apm_cli.integration.command_integrator import CommandIntegrator from apm_cli.integration.copilot_cowork_paths import CoworkResolutionError from apm_cli.integration.hook_integrator import HookIntegrator @@ -536,6 +537,7 @@ def run(ctx: InstallContext) -> None: "command": CommandIntegrator(), "hook": HookIntegrator(), "instruction": InstructionIntegrator(), + "canvas": CanvasIntegrator(), } diff --git a/src/apm_cli/install/pipeline.py b/src/apm_cli/install/pipeline.py index b15d098cf..9748e6460 100644 --- a/src/apm_cli/install/pipeline.py +++ b/src/apm_cli/install/pipeline.py @@ -378,6 +378,7 @@ def run_install_pipeline( # noqa: PLR0913, RUF100 plan_callback=None, refresh: bool = False, lockfile_only: bool = False, + trust_canvas: bool = False, ): """Install APM package dependencies. @@ -507,6 +508,7 @@ def run_install_pipeline( # noqa: PLR0913, RUF100 legacy_skill_paths=legacy_skill_paths, refresh=refresh, lockfile_only=lockfile_only, + trust_canvas=trust_canvas, ) # ------------------------------------------------------------------ diff --git a/src/apm_cli/install/request.py b/src/apm_cli/install/request.py index 31270dbea..f1a0eb989 100644 --- a/src/apm_cli/install/request.py +++ b/src/apm_cli/install/request.py @@ -63,6 +63,12 @@ class InstallRequest: # --refresh only forces re-resolution without discarding orphans. refresh: bool = False + # --trust-canvas-extensions: opt in to deploying canvas extensions + # shipped by dependencies. Canvas extensions are executable Node code, + # so dependency-provided ones are blocked by default; first-party + # (root project .apm/) canvases always deploy once the experimental flag is on. + trust_canvas: bool = False + # Plan-gate hook: if set, run_install_pipeline invokes this callable # AFTER resolve completes and BEFORE downloads begin, passing the # computed UpdatePlan. The callable returns True to proceed or diff --git a/src/apm_cli/install/service.py b/src/apm_cli/install/service.py index f01b75247..6b8155499 100644 --- a/src/apm_cli/install/service.py +++ b/src/apm_cli/install/service.py @@ -93,6 +93,7 @@ def run(self, request: InstallRequest) -> InstallResult: plan_callback=request.plan_callback, refresh=request.refresh, lockfile_only=request.lockfile_only, + trust_canvas=request.trust_canvas, ) @staticmethod diff --git a/src/apm_cli/install/services.py b/src/apm_cli/install/services.py index 1a2d49352..f1c90b8ac 100644 --- a/src/apm_cli/install/services.py +++ b/src/apm_cli/install/services.py @@ -55,6 +55,10 @@ class IntegratorBundle: instruction: BaseIntegrator command: BaseIntegrator hook: BaseIntegrator + # Optional so the ~16 existing test/prod construction sites that omit it + # keep working. Production sites (template.py, integrate_local_content, + # drift.py) pass a real CanvasIntegrator; when None the loop skips canvas. + canvas: BaseIntegrator | None = None def _deployed_path_entry( @@ -62,27 +66,17 @@ def _deployed_path_entry( project_root: Path, targets: Any, ) -> str: - """Return the lockfile-safe path string for a deployed file. - - For standard targets the entry is ``project_root``-relative. For - cowork (dynamic-root) targets the entry uses the synthetic - ``cowork://`` URI scheme so the lockfile pipeline does not attempt - a ``Path.relative_to(project_root)`` that would crash. - - Raises - ------ - RuntimeError - If the path is outside the project tree and cannot be - translated to a ``cowork://`` URI via any available target. - """ - if targets: - for _t in targets: + """Return the lockfile-safe path string for a deployed file.""" + + def _try_dynamic_root(tgts, *, strict: bool = False) -> str | None: + for _t in tgts: if _t.resolved_deploy_root is None: continue - try: - target_path.relative_to(_t.resolved_deploy_root) - except ValueError: - continue + if not strict: + try: + target_path.relative_to(_t.resolved_deploy_root) + except ValueError: + continue if _t.name == "copilot-app": from apm_cli.integration.copilot_app_db import to_lockfile_uri @@ -90,28 +84,25 @@ def _deployed_path_entry( from apm_cli.integration.copilot_cowork_paths import to_lockfile_path return to_lockfile_path(target_path, _t.resolved_deploy_root) + return None + + if targets: + result = _try_dynamic_root(targets) + if result is not None: + return result try: return target_path.relative_to(project_root).as_posix() except ValueError: - # Path is outside the project tree and no dynamic-root target - # contained it. Fall through to the legacy cowork translation - # which security-validates against deploy_root and raises - # PathTraversalError when out of bounds. + # Fallback: let to_lockfile_path run its own security + # validation (PathTraversalError) without pre-filtering. if targets: - for _t in targets: - if _t.resolved_deploy_root is None: - continue - if _t.name == "copilot-app": - from apm_cli.integration.copilot_app_db import to_lockfile_uri - - return to_lockfile_uri(target_path.name) - from apm_cli.integration.copilot_cowork_paths import to_lockfile_path - - return to_lockfile_path(target_path, _t.resolved_deploy_root) + result = _try_dynamic_root(targets, strict=True) + if result is not None: + return result raise RuntimeError( # noqa: B904 f"Cannot translate {target_path!r} to a lockfile path: " f"path is outside the project tree and no dynamic-root " - f"target matched. This is a bug — please report it." + f"target matched. This is a bug -- please report it." ) @@ -120,19 +111,7 @@ def _skill_bundle_file_entries( project_root: Path, targets: Any, ) -> list[str]: - """Return per-file lockfile entries for a deployed skill bundle directory. - - A skill is deployed as a directory (e.g. ``.agents/skills/``). Recording - only the directory leaves its contents unhashed, so skill content drift - escapes ``content-integrity`` (the ``apm audit --ci --no-drift`` gate). - This expands the bundle into per-file entries (``SKILL.md``, ``assets/``, - ``scripts/``) so ``compute_deployed_hashes`` hashes them. The directory - entry itself is recorded by the caller and intentionally excluded here. - - Mocked or file-shaped ``target_paths`` (used in unit tests) are not real - directories on disk and yield an empty list, so callers pass them through - unchanged. - """ + """Expand a deployed skill directory into per-file lockfile entries.""" try: if not (skill_dir.is_dir() and not skill_dir.is_symlink()): return [] @@ -154,11 +133,7 @@ def _log_hook_display_payloads( log_fn: Any, logger: Any, ) -> None: - """Emit per-hook-file action summaries for the hook transparency feature. - - Uses post-path-rewrite data from display_payloads, so the output - faithfully reflects what was written to disk and will be executed. - """ + """Emit per-hook-file action summaries for hook transparency.""" for _payload in payloads: _src = _payload.get("source_hook_file", "hook file") _actions = _payload.get("actions", []) @@ -174,6 +149,39 @@ def _log_hook_display_payloads( logger.verbose_detail(f" | {_jline}") +def _label_and_deploy_dir(prim_name: str, mapping, target, deploy_dir: str) -> tuple[str, str]: + """Return ``(label, deploy_dir)`` for a per-kind integration line.""" + if prim_name == "instructions" and mapping.output_compare: + # Rule-dir formats (cursor/claude/windsurf) are the output_compare + # set; derive the label from the same flag so a new rule format + # needs no edit here. + return "rule(s)", deploy_dir + if prim_name == "instructions": + return "instruction(s)", deploy_dir + if prim_name == "hooks": + if target.hooks_config_display: + deploy_dir = target.hooks_config_display + return "hook(s)", deploy_dir + if prim_name == "canvas": + return "canvas extension(s)", deploy_dir + return prim_name, deploy_dir + + +def _emit_integration_hints(prim_name: str, info: dict, log_integration) -> None: + """Emit per-primitive 'next step' hints after an integration line.""" + # copilot-app workflows arrive disabled: the row lands enabled=0 and the + # user must flip the toggle in the Copilot App's Workflows tab before the + # schedule fires. + if any(p.startswith("copilot-app/") for p in info["paths"]) and info["files"] > 0: + log_integration( + " |-- workflows arrive disabled; enable from the Copilot App's Workflows tab" + ) + # Canvas extensions are discovered by Copilot CLI at session start, so a + # freshly-deployed canvas is not picked up mid-session. + if prim_name == "canvas" and (info["files"] > 0 or info["adopted"] > 0): + log_integration(" |-- reload the Copilot session (/clear) or restart to load the canvas") + + def integrate_package_primitives( package_info: Any, project_root: Path, @@ -190,22 +198,12 @@ def integrate_package_primitives( ctx: InstallContext | None = None, scratch_root: Path | None = None, policy: Any = None, + is_first_party: bool = False, ) -> dict: """Run the full integration pipeline for a single package. - Iterates over *targets* (``TargetProfile`` list) and dispatches each - primitive to the appropriate integrator via the target-driven API. - Skills are handled separately because ``SkillIntegrator`` already - routes across all targets internally. - - When *scope* is ``InstallScope.USER``, targets and primitives that - do not support user-scope deployment are silently skipped. - - When *ctx* is provided, the cowork non-skill primitive warning - (Amendment 6) is emitted once per install run for packages that - contain non-skill primitives when the cowork target is active. - - Returns a dict with integration counters and the list of deployed file paths. + Iterates over *targets* and dispatches each primitive to the + appropriate integrator. Returns integration counters and deployed paths. """ from apm_cli.integration.dispatch import get_dispatch_table @@ -220,6 +218,7 @@ def integrate_package_primitives( "instructions": 0, "commands": 0, "hooks": 0, + "canvases": 0, "links_resolved": 0, "deployed_files": [], } @@ -321,6 +320,7 @@ def _format_target_collapse(paths: list[str], verbose: bool) -> tuple[str, list[ "commands": integrators.command, "instructions": integrators.instruction, "hooks": integrators.hook, + "canvas": integrators.canvas, "skills": integrators.skill, } @@ -333,6 +333,12 @@ def _format_target_collapse(paths: list[str], verbose: bool) -> tuple[str, list[ if _entry.multi_target: continue # skills handled separately _integrator = _INTEGRATOR_KWARGS[_prim_name] + # A primitive can be statically present on a target (e.g. the + # copilot canvas mapping) while a given IntegratorBundle omits its + # integrator (None). Skip rather than crash so test/replay bundles + # that don't wire the integrator simply no-op that primitive. + if _integrator is None: + continue _agg_files = 0 _agg_adopted = 0 _agg_paths: list[str] = [] @@ -354,6 +360,17 @@ def _format_target_collapse(paths: list[str], verbose: bool) -> tuple[str, list[ # don't accept this kwarg, so include it only for hooks. if _prim_name == "hooks": _call_kwargs["user_scope"] = scope is InstallScope.USER + # Canvas alone needs the trust signal: dependency-provided + # canvases are executable code blocked unless the operator + # passed --trust-canvas-extensions. First-party (root/local) + # status is decided by the CALL PATH, not by a package-name + # string a dependency could spoof: only integrate_local_content + # passes is_first_party=True. Every dependency call defaults to + # False, so a dependency canvas always requires the trust flag. + if _prim_name == "canvas": + _call_kwargs["trust_canvas"] = bool(getattr(ctx, "trust_canvas", False)) + _call_kwargs["is_first_party"] = is_first_party + _call_kwargs["package_name"] = package_name _int_result = getattr(_integrator, _entry.integrate_method)( _target, package_info, @@ -392,22 +409,11 @@ def _format_target_collapse(paths: list[str], verbose: bool) -> tuple[str, list[ if _mapping.subdir else f"{_effective_root}/" ) - if _prim_name == "instructions" and _mapping.output_compare: - # Rule-dir formats (cursor/claude/windsurf) are the - # output_compare set; derive the label from the same flag so a - # new rule format needs no edit here. - _label = "rule(s)" - elif _prim_name == "instructions": - _label = "instruction(s)" - elif _prim_name == "hooks": - if _target.hooks_config_display: - _deploy_dir = _target.hooks_config_display - _label = "hook(s)" + _label, _deploy_dir = _label_and_deploy_dir(_prim_name, _mapping, _target, _deploy_dir) + if _prim_name == "hooks": _agg_hook_payloads.extend( p for p in getattr(_int_result, "display_payloads", []) or [] ) - else: - _label = _prim_name _agg_paths.append(_deploy_dir) if _agg_files > 0 or _agg_adopted > 0: @@ -456,16 +462,7 @@ def _format_target_collapse(paths: list[str], verbose: bool) -> tuple[str, list[ _log_integration, logger, ) - # Emit a one-line "next step" hint when copilot-app workflows - # were integrated: the row lands enabled=0 and the user has to - # flip the toggle in the Copilot App's Workflows tab before the - # schedule fires. This is the "failure mode is the product" - # surface for project-scope ride-along installs where a - # contributor may not have read the integration doc. - if any(p.startswith("copilot-app/") for p in _info["paths"]) and _info["files"] > 0: - _log_integration( - " |-- workflows arrive disabled; enable from the Copilot App's Workflows tab" - ) + _emit_integration_hints(_prim_name, _info, _log_integration) skill_result = integrators.skill.integrate_package_skill( package_info, @@ -587,6 +584,7 @@ def integrate_local_content( Returns a dict with integration counters and deployed file paths, same shape as ``integrate_package_primitives()``. """ + from ..integration.canvas_integrator import CanvasIntegrator from ..models.apm_package import APMPackage, PackageInfo, PackageType if source_root is None: @@ -615,6 +613,7 @@ def integrate_local_content( instruction=instruction_integrator, command=command_integrator, hook=hook_integrator, + canvas=CanvasIntegrator(), ), force=force, managed_files=managed_files, @@ -623,6 +622,7 @@ def integrate_local_content( logger=logger, scope=scope, ctx=ctx, + is_first_party=True, ) @@ -648,6 +648,7 @@ def integrate_local_bundle( logger: InstallLogger | None = None, scope: InstallScope | None = None, alias: str | None = None, + trust_canvas: bool = False, ) -> dict: """Integrate a detected local bundle into project / user scope. @@ -677,6 +678,11 @@ def integrate_local_bundle( logger: Install-flow logger. scope: ``InstallScope`` (project vs user) for downstream consumers. alias: Slug override from ``--as``. + trust_canvas: When ``True``, allow executable canvas extension + bundles (``extensions//extension.mjs``) to deploy from the + offline bundle. Defaults to ``False`` (fail closed) so a + vendored bundle cannot smuggle executable canvas code past the + dependency trust gate. Returns: Dict with keys ``deployed_files`` (list[str]), @@ -738,6 +744,42 @@ def integrate_local_bundle( pack_files = _filtered_pack_files slug = alias or bundle_info.package_id + + # Security + feature gate: canvas extensions are executable Node bundles + # (``extension.mjs``). A local / offline bundle copies its files + # verbatim WITHOUT routing through ``CanvasIntegrator``, so neither the + # experimental feature flag nor its trust gate would otherwise apply + # here. Require BOTH gates: the ``canvas`` experimental flag must be ON + # (feature availability) AND ``--trust-canvas-extensions`` must be set + # (executable-code trust). Fail closed -- drop canvas paths when either + # gate is missing. + from ..core.experimental import is_enabled + from ..integration.canvas_integrator import is_canvas_bundle_path + + _canvas_enabled = is_enabled("canvas") + if not (_canvas_enabled and trust_canvas): + _blocked = sorted(r for r in pack_files if is_canvas_bundle_path(r)) + if _blocked: + for _r in _blocked: + pack_files.pop(_r, None) + # Flag ON but untrusted: this is a genuine blocked deploy, so + # count it as skipped and surface the trust opt-in. Flag OFF: + # the canvas type does not exist yet, so drop the paths silently + # WITHOUT inflating the skip count -- mirroring the silent no-op + # of the normal CanvasIntegrator path when the flag is off. + if _canvas_enabled: + skipped += len(_blocked) + _msg = ( + f"Blocked {len(_blocked)} canvas extension file(s) from bundle " + f"'{slug}': canvas extensions are executable extension.mjs code " + "and are not deployed from bundles by default. Re-run with " + "'--trust-canvas-extensions' to deploy them to .github/extensions/." + ) + if diagnostics is not None: + diagnostics.warn(message=_msg, package=str(slug)) + elif logger is not None: + logger.warning(_msg) + if logger: logger.verbose_detail( f"Integrating local bundle '{slug}' " @@ -794,7 +836,8 @@ def integrate_local_bundle( # can merge them into the target's AGENTS.md / GEMINI.md / # equivalent. Deploying them verbatim to ``/instructions/`` # is a no-op for these clients. - _first_seg = rel.split("/", 1)[0] if "/" in rel else "" + _rel_norm = rel.replace("\\", "/") + _first_seg = _rel_norm.split("/", 1)[0] if "/" in _rel_norm else "" if _first_seg == "instructions" and "instructions" not in (target.primitives or {}): # Slug must be safe for filesystem path construction -- # ``package_id`` originates from untrusted ``plugin.json``. @@ -853,6 +896,14 @@ def integrate_local_bundle( dest = stage_root / _rel_under_instructions deploy_root = stage_root else: + # Canvas extensions are Copilot-only. A plugin bundle is + # target-agnostic, so guard against depositing an + # ``extensions/`` tree into a non-Copilot client root + # (e.g. ``.claude/extensions/``). Skip silently for other + # targets; the trust filter above already removed these + # entries entirely when canvas was not trusted. + if _first_seg.lower() == "extensions" and target.name != "copilot": + continue # Route the file to the correct deploy root. If the first # path segment matches a primitive with an explicit # ``deploy_root`` (e.g. ``skills/`` -> ``.agents/``), use diff --git a/src/apm_cli/install/template.py b/src/apm_cli/install/template.py index 82d189399..93951a12d 100644 --- a/src/apm_cli/install/template.py +++ b/src/apm_cli/install/template.py @@ -82,6 +82,7 @@ def _integrate_materialization( instruction=ctx.integrators["instruction"], command=ctx.integrators["command"], hook=ctx.integrators["hook"], + canvas=ctx.integrators.get("canvas"), ), force=ctx.force, managed_files=ctx.managed_files, @@ -109,6 +110,7 @@ def _integrate_materialization( "instructions", "commands", "hooks", + "canvases", ) for k in (*mutation_keys, "links_resolved"): deltas[k] = int_result[k] diff --git a/src/apm_cli/integration/canvas_integrator.py b/src/apm_cli/integration/canvas_integrator.py new file mode 100644 index 000000000..dce0f5901 --- /dev/null +++ b/src/apm_cli/integration/canvas_integrator.py @@ -0,0 +1,508 @@ +"""Canvas extension integration for APM packages (experimental, Copilot-only). + +A *canvas* is a GitHub Copilot CLI extension: a directory bundle whose +entry file is ``extension.mjs`` (executable Node.js) plus optional sibling +assets. Authors place a canvas under ``.apm/extensions//`` and +``apm install`` deploys it verbatim to ``.github/extensions//`` so +Copilot CLI can discover it in the session. + +Two independent gates protect this surface: + +* The ``canvas`` experimental feature flag turns the primitive ON at all + (feature availability -- NOT a security gate). +* A trust gate protects against arbitrary executable code: a canvas + shipped by a *dependency* is blocked by default and requires the + operator to pass ``--trust-canvas-extensions``. The author's own + first-party (root/local) ``.apm/extensions/`` deploys freely once the + experimental flag is on. + +The integrator is Copilot-only. At **project scope** a canvas deploys to +``.github/extensions//``. At **user scope** (``apm install --global``) +a *dependency-provided* canvas deploys to ``~/.copilot/extensions//`` so +it is available in every Copilot session; the canvas ``PrimitiveMapping`` lives +solely on the ``copilot`` target. Global canvas install is intentionally +limited for the MVP: only dependency-provided canvases are supported (so the +dependency lockfile tracks them and uninstall can prune them), the operator +must always pass ``--trust-canvas-extensions`` (the blast radius is the whole +account), and only the default ``~/.copilot`` location is honored (a custom +``$COPILOT_HOME`` is refused). +""" + +from __future__ import annotations + +import re +import shutil +from pathlib import Path +from typing import TYPE_CHECKING + +from apm_cli.core.experimental import is_enabled +from apm_cli.install.cache_pin import MARKER_FILENAME +from apm_cli.integration.base_integrator import BaseIntegrator, IntegrationResult +from apm_cli.utils.path_security import ( + PathTraversalError, + ensure_path_within, + validate_path_segments, +) +from apm_cli.utils.paths import portable_relpath + +if TYPE_CHECKING: + from apm_cli.integration.targets import TargetProfile + +#: Entry file that marks a directory under ``.apm/extensions/`` as a canvas. +CANVAS_MARKER = "extension.mjs" + +#: Permitted characters in a canvas directory name. The name becomes a +#: filesystem path segment and a Copilot extension id, so it is kept to a +#: conservative portable set. +_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$") + +#: Windows reserved device names (case-insensitive). A canvas directory may +#: not use one because the name becomes a path segment. +_RESERVED_NAMES = frozenset( + { + "con", + "prn", + "aux", + "nul", + *(f"com{i}" for i in range(1, 10)), + *(f"lpt{i}" for i in range(1, 10)), + } +) + + +def is_canvas_bundle_path(rel: str) -> bool: + """Return True when a bundle-relative path belongs to a canvas extension. + + Used by the offline / local-bundle install and ``apm unpack`` code + paths -- which copy bundle files verbatim and do NOT route through + :class:`CanvasIntegrator` -- to detect executable canvas content so the + trust gate can be enforced there too. Without this a vendored bundle + could smuggle an executable ``extension.mjs`` past the dependency trust + gate. + + A path is a canvas path when an ``extensions`` segment appears either as + the first path component (plugin-format bundle, e.g. + ``extensions//extension.mjs``) or immediately under a client root + dot-directory (legacy / direct deploy paths, e.g. + ``.github/extensions//extension.mjs``). + """ + parts = [seg for seg in rel.replace("\\", "/").split("/") if seg] + for idx, seg in enumerate(parts): + if seg.lower() != "extensions": + continue + if idx == 0: + return True + if parts[idx - 1].startswith("."): + return True + return False + + +class CanvasIntegrator(BaseIntegrator): + """Deploys Copilot canvas extension bundles into ``.github/extensions/``.""" + + # ------------------------------------------------------------------ + # Discovery + # ------------------------------------------------------------------ + + @staticmethod + def find_canvas_bundles(package_path: Path) -> list[Path]: + """Return canvas bundle directories under ``.apm/extensions/``. + + A bundle is an *immediate* subdirectory of ``.apm/extensions/`` that + contains an ``extension.mjs`` entry file. Symlinked bundle + directories and the base ``extensions/`` directory itself are + rejected for safety; resolved paths must stay within *package_path*. + """ + base = package_path / ".apm" / "extensions" + if not base.is_dir() or base.is_symlink(): + return [] + resolved_root = package_path.resolve() + bundles: list[Path] = [] + for child in sorted(base.iterdir()): + if child.is_symlink() or not child.is_dir(): + continue + if not child.resolve().is_relative_to(resolved_root): + continue + marker = child / CANVAS_MARKER + if marker.is_file() and not marker.is_symlink(): + bundles.append(child) + return bundles + + # ------------------------------------------------------------------ + # Name validation + # ------------------------------------------------------------------ + + @staticmethod + def _validate_canvas_name(name: str) -> None: + """Raise ``PathTraversalError`` / ``ValueError`` for unsafe names.""" + validate_path_segments(name, context="canvas name") + if not _NAME_RE.match(name): + raise ValueError( + f"Invalid canvas name '{name}': only letters, digits, '.', '_' and '-' are allowed" + ) + if name.startswith(".") or name.endswith("."): + raise ValueError(f"Invalid canvas name '{name}': must not start or end with '.'") + if name.lower() in _RESERVED_NAMES: + raise ValueError(f"Invalid canvas name '{name}': reserved device name") + + # ------------------------------------------------------------------ + # Target-driven API + # ------------------------------------------------------------------ + + def integrate_canvases_for_target( + self, + target: TargetProfile, + package_info, + project_root: Path, + *, + force: bool = False, + managed_files: set[str] | None = None, + diagnostics=None, + scope=None, + trust_canvas: bool = False, + is_first_party: bool = False, + package_name: str = "", + ) -> IntegrationResult: + """Deploy canvas bundles for a single *target* (copilot only). + + Returns an empty result (no-op) when the experimental flag is off, the + target is not copilot, or the mapping is absent. + + Trust model: + + * **Project scope** -- a dependency-provided canvas requires + *trust_canvas*; a first-party (root/local) canvas deploys freely + once the flag is on. + * **User scope** (``--global``) -- only *dependency-provided* canvases + deploy, and they ALWAYS require *trust_canvas* (full-account blast + radius). First-party user-scope canvases are refused because the + user-scope lockfile pipeline does not track them, so uninstall could + not prune the executable bundle. A non-default ``$COPILOT_HOME`` is + also refused (APM deploys global canvases to ``~/.copilot`` only). + """ + empty = IntegrationResult(0, 0, 0, []) + + if not is_enabled("canvas"): + return empty + + mapping = target.primitives.get("canvas") + if mapping is None or target.name != "copilot": + return empty + + bundles = self.find_canvas_bundles(Path(package_info.install_path)) + if not bundles: + return empty + + from apm_cli.core.scope import InstallScope + + is_user = scope is InstallScope.USER + if is_user: + if self._copilot_home_is_nondefault(): + self._warn( + diagnostics, + "Skipping global canvas install: APM deploys global canvases to " + "~/.copilot/extensions only, but a non-default $COPILOT_HOME is set. " + "Install the canvas at project scope, or unset $COPILOT_HOME.", + package_name, + ) + return empty + if is_first_party: + self._warn( + diagnostics, + "Skipping global canvas install for first-party '.apm/extensions/': " + "global (user-scope) canvases are only supported when provided by a " + "dependency package so APM can track and later remove them. Package " + "the canvas and install it as a dependency with --global.", + package_name, + ) + return empty + + # Trust gate: a canvas is arbitrary executable Node code. At user scope + # every canvas is dependency-provided (first-party is refused above) and + # the blast radius is the whole account, so trust is always required. At + # project scope a first-party canvas is the author's own and deploys + # freely. + needs_trust = is_user or not is_first_party + if needs_trust and not trust_canvas: + self._emit_trust_block( + bundles, package_name, project_root, mapping, target, diagnostics + ) + return empty + + managed = self.normalize_managed_files(managed_files) or set() + effective_root = mapping.deploy_root or target.root_dir + extensions_dir = project_root / effective_root / mapping.subdir + + files_integrated = 0 + files_skipped = 0 + files_adopted = 0 + target_paths: list[Path] = [] + + for bundle in bundles: + outcome = self._deploy_bundle( + bundle, + extensions_dir, + project_root, + managed=managed, + force=force, + diagnostics=diagnostics, + package_name=package_name, + target_paths=target_paths, + ) + if outcome == "integrated": + files_integrated += 1 + elif outcome == "adopted": + files_adopted += 1 + elif outcome == "skipped": + files_skipped += 1 + + return IntegrationResult( + files_integrated=files_integrated, + files_updated=0, + files_skipped=files_skipped, + target_paths=target_paths, + links_resolved=0, + files_adopted=files_adopted, + ) + + def sync_for_target( + self, + target: TargetProfile, + apm_package, + project_root: Path, + managed_files: set[str] | None = None, + ) -> dict[str, int]: + """Remove APM-managed canvas files for a single *target*. + + Not gated by the experimental flag: uninstall must always be able + to remove previously-deployed canvases even after the flag is off. + """ + mapping = target.primitives.get("canvas") + if mapping is None: + return {"files_removed": 0, "errors": 0} + + effective_root = mapping.deploy_root or target.root_dir + prefix = f"{effective_root}/{mapping.subdir}/" + stats = self.sync_remove_files( + project_root, + managed_files, + prefix=prefix, + targets=[target], + ) + # Remove the now-empty .github/extensions// directories left + # behind once their files are gone. + if managed_files: + removed_paths = [ + project_root / rel + for rel in managed_files + if rel.replace("\\", "/").startswith(prefix) + and BaseIntegrator.validate_deploy_path(rel, project_root, targets=[target]) + ] + BaseIntegrator.cleanup_empty_parents( + removed_paths, stop_at=project_root / effective_root + ) + return stats + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _deploy_bundle( + self, + bundle: Path, + extensions_dir: Path, + project_root: Path, + *, + managed: set[str], + force: bool, + diagnostics, + package_name: str, + target_paths: list[Path], + ) -> str: + """Deploy one canvas bundle atomically. + + Returns one of ``"integrated"``, ``"adopted"`` or ``"skipped"``. + The bundle is treated as a unit: all source files are planned and + validated first, and any unmanaged collision skips the *whole* + bundle (unless *force*) so a half-new/half-old executable extension + is never produced. + """ + name = bundle.name + try: + self._validate_canvas_name(name) + except (PathTraversalError, ValueError) as exc: + self._warn(diagnostics, f"Skipping canvas '{name}': {exc}", package_name) + return "skipped" + + canvas_root = extensions_dir / name + try: + ensure_path_within(canvas_root.parent.resolve() / name, extensions_dir.resolve()) + except PathTraversalError as exc: + self._warn( + diagnostics, f"Rejected canvas target path for '{name}': {exc}", package_name + ) + return "skipped" + + planned = self._plan_bundle_files( + bundle, canvas_root, project_root, diagnostics, name, package_name + ) + if planned is None: + return "skipped" + if not planned: + # Bundle had only the marker filtered out / no copyable content. + return "skipped" + + # A planned destination that already exists as a directory (or other + # non-regular file) cannot be overwritten by ``shutil.copyfile`` -- + # even under ``--force``. Treat it as an unsafe collision and skip + # the whole bundle so we never crash mid-deploy and leave a + # half-written executable extension behind. + non_file = next( + (rel for _src, dest, rel in planned if dest.exists() and not dest.is_file()), + None, + ) + if non_file is not None: + self._warn( + diagnostics, + f"Skipping canvas '{name}' -- a directory exists at {non_file} " + "where a file is expected; cannot overwrite safely.", + package_name, + ) + return "skipped" + + # Atomic collision pre-pass: a single unmanaged collision skips the + # entire bundle unless force is set. + collision = next( + ( + rel + for _src, dest, rel in planned + if dest.exists() and rel not in managed and not force + ), + None, + ) + if collision is not None: + self._warn( + diagnostics, + f"Skipping canvas '{name}' -- local file exists at {collision} " + "(not managed by APM). Use 'apm install --force' to overwrite.", + package_name, + ) + return "skipped" + + # Adopt when every planned file already exists byte-identical: keep + # tracking the files in deployed_files without rewriting them. + if all(self.is_content_identical_to_source(dest, src) for src, dest, _rel in planned): + for _src, dest, _rel in planned: + target_paths.append(dest) + return "adopted" + + for src, dest, _rel in planned: + dest.parent.mkdir(parents=True, exist_ok=True) + # Guard: reject dest if it is a symlink (TOCTOU defence -- + # a symlink could redirect shutil.copyfile to an arbitrary + # location outside the project root). + if dest.exists() and dest.is_symlink(): + self._warn( + diagnostics, + f"Skipping canvas '{name}' -- destination {_rel} is a symlink.", + package_name, + ) + return "skipped" + shutil.copyfile(src, dest) + target_paths.append(dest) + return "integrated" + + def _plan_bundle_files( + self, + bundle: Path, + canvas_root: Path, + project_root: Path, + diagnostics, + name: str, + package_name: str = "", + ) -> list[tuple[Path, Path, str]] | None: + """Walk *bundle* and return ``(src, dest, rel)`` triples to copy. + + Returns ``None`` when a containment / safety check fails (the whole + bundle is then skipped by the caller). Symlinks and the + ``.apm-pin`` cache marker are excluded, mirroring + ``security.gate.ignore_non_content``. + """ + planned: list[tuple[Path, Path, str]] = [] + for src in sorted(bundle.rglob("*")): + if src.is_symlink(): + continue + if src.name == MARKER_FILENAME: + continue + if not src.is_file(): + continue + rel_within = src.relative_to(bundle) + dest = canvas_root / rel_within + try: + ensure_path_within(dest, canvas_root) + except PathTraversalError as exc: + self._warn( + diagnostics, + f"Rejected canvas file path in '{name}': {exc}", + "", + ) + return None + rel = portable_relpath(dest, project_root) + planned.append((src, dest, rel)) + return planned + + def _emit_trust_block( + self, + bundles: list[Path], + package_name: str, + project_root: Path, + mapping, + target: TargetProfile, + diagnostics, + ) -> None: + """Record a diagnostic explaining why dependency canvases were blocked.""" + if diagnostics is None: + return + effective_root = mapping.deploy_root or target.root_dir + names = ", ".join(sorted(b.name for b in bundles)) + deploy_dir = f"{effective_root}/{mapping.subdir}/" + pkg = package_name or "dependency" + diagnostics.warn( + message=( + f"Blocked {len(bundles)} canvas extension(s) ({names}) from '{pkg}': " + f"canvas extensions are executable {CANVAS_MARKER} code and are not " + f"deployed from dependencies by default. Re-run with " + f"'--trust-canvas-extensions' to deploy them to {deploy_dir}." + ), + package=package_name or "", + ) + + @staticmethod + def _copilot_home_is_nondefault() -> bool: + """Return True when ``$COPILOT_HOME`` points somewhere other than ~/.copilot. + + APM deploys global canvases to ``~/.copilot/extensions`` (home-relative + so the lockfile records a clean, prunable path). A custom + ``$COPILOT_HOME`` would make Copilot scan a different directory and + would also break the home-relative lockfile encoding, so global canvas + install is refused in that case for the MVP. An unset (or empty) + ``$COPILOT_HOME`` resolves to the default and is allowed. + """ + import os + + env = os.environ.get("COPILOT_HOME", "").strip() + if not env: + return False + resolved = Path(env).expanduser().resolve(strict=False) + default = (Path.home() / ".copilot").resolve(strict=False) + return resolved != default + + @staticmethod + def _warn(diagnostics, message: str, package_name: str) -> None: + """Emit a warning through diagnostics when available, else console.""" + if diagnostics is not None: + diagnostics.warn(message=message, package=package_name or "") + else: + from apm_cli.utils.console import _rich_warning + + _rich_warning(message) diff --git a/src/apm_cli/integration/dispatch.py b/src/apm_cli/integration/dispatch.py index 6a521102a..09fa1464f 100644 --- a/src/apm_cli/integration/dispatch.py +++ b/src/apm_cli/integration/dispatch.py @@ -43,6 +43,7 @@ def _build_dispatch() -> dict[str, PrimitiveDispatch]: Deferred import to avoid circular dependencies at module level. """ from apm_cli.integration.agent_integrator import AgentIntegrator + from apm_cli.integration.canvas_integrator import CanvasIntegrator from apm_cli.integration.command_integrator import CommandIntegrator from apm_cli.integration.hook_integrator import HookIntegrator from apm_cli.integration.instruction_integrator import InstructionIntegrator @@ -68,6 +69,12 @@ def _build_dispatch() -> dict[str, PrimitiveDispatch]: "hooks": PrimitiveDispatch( HookIntegrator, "integrate_hooks_for_target", "sync_integration", "hooks" ), + "canvas": PrimitiveDispatch( + CanvasIntegrator, + "integrate_canvases_for_target", + "sync_for_target", + "canvases", + ), "skills": PrimitiveDispatch( SkillIntegrator, "integrate_package_skill", diff --git a/src/apm_cli/integration/targets.py b/src/apm_cli/integration/targets.py index 409e49853..3027c5c48 100644 --- a/src/apm_cli/integration/targets.py +++ b/src/apm_cli/integration/targets.py @@ -479,12 +479,12 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None: deploy_root=".agents", ), "hooks": PrimitiveMapping("hooks", ".json", "github_hooks"), + "canvas": PrimitiveMapping("extensions", "", "copilot_canvas"), }, auto_create=True, detect_by_dir=True, user_supported="partial", user_root_dir=".copilot", - unsupported_user_primitives=(), user_primitive_overrides={ "instructions": PrimitiveMapping("", ".md", "copilot_user_instructions"), }, diff --git a/src/apm_cli/models/validation.py b/src/apm_cli/models/validation.py index 668a9710e..148940bc5 100644 --- a/src/apm_cli/models/validation.py +++ b/src/apm_cli/models/validation.py @@ -144,6 +144,23 @@ def _has_hook_json(package_path: Path) -> bool: return False +def _canvas_extension_names(package_path: Path) -> list[str]: + """Return sorted canvas bundle names declared under .apm/extensions/. + + A canvas bundle is a directory carrying an executable ``extension.mjs`` + marker (experimental, Copilot-only). Surfacing it lets validation treat a + canvas-only package as non-empty and warn that it ships gated executable + code. The scan is independent of the ``canvas`` experimental flag so an + author is always informed about what their package contains. + """ + try: + from ..integration.canvas_integrator import CanvasIntegrator + + return [bundle.name for bundle in CanvasIntegrator.find_canvas_bundles(package_path)] + except Exception: + return [] + + @dataclass(frozen=True) class DetectionEvidence: """Snapshot of the file-system signals that drove classification. @@ -765,6 +782,20 @@ def _validate_apm_package_with_yml( if not has_primitives: has_primitives = _has_hook_json(package_path) + # Canvas extensions: experimental, Copilot-only bundles that ship an + # executable extension.mjs. They count as primitives (so a canvas-only + # package is not mis-flagged as empty) and earn an explicit warning that + # they are gated executable code. + canvas_names = _canvas_extension_names(package_path) + if canvas_names: + has_primitives = True + result.add_warning( + "Canvas extension(s) found (experimental, Copilot-only): " + f"{', '.join(canvas_names)}. These ship executable extension.mjs " + "code; consumers must enable the 'canvas' experimental flag, and " + "dependents must pass --trust-canvas-extensions to install them." + ) + if not has_primitives: result.add_warning(f"No primitive files found in {APM_DIR}/ directory") diff --git a/tests/unit/bundle/test_plugin_exporter_canvas.py b/tests/unit/bundle/test_plugin_exporter_canvas.py new file mode 100644 index 000000000..6022d7676 --- /dev/null +++ b/tests/unit/bundle/test_plugin_exporter_canvas.py @@ -0,0 +1,51 @@ +"""Unit tests: the plugin exporter preserves canvas extensions in the bundle. + +Canvas extensions live under ``.apm/extensions//`` (and, for +plugin-native repos, root ``extensions/``). The default plugin pack +format must carry them verbatim so an offline bundle can deliver a +canvas; the files stay inert until the consumer enables the ``canvas`` +experimental flag and passes ``--trust-canvas-extensions`` at install. +""" + +from __future__ import annotations + +from pathlib import Path + +from apm_cli.bundle.plugin_exporter import ( + _collect_apm_components, + _collect_root_plugin_components, +) + + +def test_collect_apm_components_includes_canvas(tmp_path: Path): + apm = tmp_path / ".apm" + (apm / "extensions" / "demo").mkdir(parents=True) + (apm / "agents").mkdir(parents=True) + (apm / "extensions" / "demo" / "extension.mjs").write_text("export default {};\n") + (apm / "extensions" / "demo" / "helper.js").write_text("export const h = 1;\n") + (apm / "agents" / "a1.agent.md").write_text("---\nname: a1\ndescription: d\n---\nb\n") + + rels = sorted(rel for _src, rel in _collect_apm_components(apm)) + + assert "extensions/demo/extension.mjs" in rels + assert "extensions/demo/helper.js" in rels + assert "agents/a1.agent.md" in rels + + +def test_collect_root_plugin_components_includes_canvas(tmp_path: Path): + (tmp_path / "extensions" / "widget").mkdir(parents=True) + (tmp_path / "extensions" / "widget" / "extension.mjs").write_text("export default {};\n") + + rels = sorted(rel for _src, rel in _collect_root_plugin_components(tmp_path)) + + assert "extensions/widget/extension.mjs" in rels + + +def test_collect_apm_components_no_extensions_dir(tmp_path: Path): + apm = tmp_path / ".apm" + (apm / "agents").mkdir(parents=True) + (apm / "agents" / "a1.agent.md").write_text("---\nname: a1\ndescription: d\n---\nb\n") + + rels = sorted(rel for _src, rel in _collect_apm_components(apm)) + + assert not any(r.startswith("extensions/") for r in rels) diff --git a/tests/unit/commands/test_install_context.py b/tests/unit/commands/test_install_context.py index 57aead3b1..57e2efe48 100644 --- a/tests/unit/commands/test_install_context.py +++ b/tests/unit/commands/test_install_context.py @@ -58,6 +58,7 @@ class TestInstallContextFields: "protocol_pref", "allow_protocol_fallback", "trust_transitive_mcp", + "trust_canvas", "no_policy", "install_mode", "packages", @@ -121,6 +122,7 @@ def _build_minimal(self, **overrides): protocol_pref=sentinel.PROTO, allow_protocol_fallback=False, trust_transitive_mcp=False, + trust_canvas=False, no_policy=False, install_mode=sentinel.MODE, packages=(), @@ -167,6 +169,7 @@ def test_round_trip_required_fields(self): protocol_pref=sentinel.PROTO, allow_protocol_fallback=True, trust_transitive_mcp=True, + trust_canvas=True, no_policy=True, install_mode=sentinel.MODE, packages=("owner/repo",), @@ -220,6 +223,7 @@ def test_round_trip_optional_fields(self): protocol_pref=sentinel.PROTO, allow_protocol_fallback=False, trust_transitive_mcp=False, + trust_canvas=False, no_policy=False, install_mode=sentinel.MODE, packages=(), diff --git a/tests/unit/commands/test_install_context_and_resolution.py b/tests/unit/commands/test_install_context_and_resolution.py index 87a8bfc4f..2512a287e 100644 --- a/tests/unit/commands/test_install_context_and_resolution.py +++ b/tests/unit/commands/test_install_context_and_resolution.py @@ -563,6 +563,7 @@ def test_refresh_default_is_false(self) -> None: protocol_pref=None, allow_protocol_fallback=False, trust_transitive_mcp=False, + trust_canvas=False, no_policy=False, install_mode=None, packages=(), @@ -600,6 +601,7 @@ def test_context_is_mutable(self) -> None: protocol_pref=None, allow_protocol_fallback=False, trust_transitive_mcp=False, + trust_canvas=False, no_policy=False, install_mode=None, packages=(), diff --git a/tests/unit/commands/test_install_phase3.py b/tests/unit/commands/test_install_phase3.py index a14aa35be..b49cf4131 100644 --- a/tests/unit/commands/test_install_phase3.py +++ b/tests/unit/commands/test_install_phase3.py @@ -564,6 +564,7 @@ def test_refresh_default_is_false(self) -> None: protocol_pref=None, allow_protocol_fallback=False, trust_transitive_mcp=False, + trust_canvas=False, no_policy=False, install_mode=None, packages=(), @@ -601,6 +602,7 @@ def test_context_is_mutable(self) -> None: protocol_pref=None, allow_protocol_fallback=False, trust_transitive_mcp=False, + trust_canvas=False, no_policy=False, install_mode=None, packages=(), diff --git a/tests/unit/install/test_architecture_invariants.py b/tests/unit/install/test_architecture_invariants.py index 127df878f..7a6aff32c 100644 --- a/tests/unit/install/test_architecture_invariants.py +++ b/tests/unit/install/test_architecture_invariants.py @@ -208,12 +208,20 @@ def test_install_py_under_legacy_budget(): ``__exit__`` in the existing ``finally``). All glue at the handler boundary; the chdir + source-root-override mechanism lives in ``apm_cli/install/root_redirect.py`` and ``apm_cli/core/scope.py``. + + Experimental canvas support raised 2085 -> 2110 to add the + ``--trust-canvas-extensions`` Click option plus its signature param, + the ``trust_canvas`` ``InstallContext`` field, and the trust-signal + wiring through ``_install_apm_dependencies`` / ``InstallRequest`` and + the local-bundle handler. All glue at the handler boundary; the + integrator and its trust gate live in + ``apm_cli/integration/canvas_integrator.py``. """ install_py = Path(__file__).resolve().parents[3] / "src" / "apm_cli" / "commands" / "install.py" assert install_py.is_file() n = _line_count(install_py) - assert n <= 2085, ( - f"commands/install.py grew to {n} LOC (budget 2085). " + assert n <= 2110, ( + f"commands/install.py grew to {n} LOC (budget 2110). " "Do NOT trim cosmetically -- engage the python-architecture skill " "(.apm/skills/python-architecture/SKILL.md) and propose an " "extraction into apm_cli/install/." diff --git a/tests/unit/install/test_install_local_bundle.py b/tests/unit/install/test_install_local_bundle.py index 738d1b4c4..5f64639f6 100644 --- a/tests/unit/install/test_install_local_bundle.py +++ b/tests/unit/install/test_install_local_bundle.py @@ -515,3 +515,79 @@ def test_as_rejected_on_registry_install( result = _invoke(project, monkeypatch, "owner/pkg", "--as", "alias") assert result.exit_code != 0 assert "--as requires a local bundle" in result.output + + +class TestLocalBundleCanvasTrust: + """A vendored bundle must not smuggle an executable canvas past the gates. + + Both gates must hold: the ``canvas`` experimental flag (feature + availability) and ``--trust-canvas-extensions`` (executable-code trust). + """ + + def test_canvas_blocked_without_trust(self, tmp_path, monkeypatch): + bundle = _make_bundle( + tmp_path, + files={ + "agents/a.md": "# Agent\n", + "extensions/widget/extension.mjs": "export default {};\n", + }, + ) + project = _make_project(tmp_path) + result = _invoke(project, monkeypatch, str(bundle), "--target", "copilot") + assert result.exit_code == 0, result.output + # Agent deploys; canvas is blocked. + assert (project / ".github" / "agents" / "a.md").exists() + assert not (project / ".github" / "extensions" / "widget").exists() + + def test_canvas_deployed_with_trust_and_flag(self, tmp_path, monkeypatch): + import apm_cli.config as _conf + + monkeypatch.setattr(_conf, "_config_cache", {"experimental": {"canvas": True}}) + bundle = _make_bundle( + tmp_path, + files={ + "agents/a.md": "# Agent\n", + "extensions/widget/extension.mjs": "export default {};\n", + }, + ) + project = _make_project(tmp_path) + result = _invoke( + project, + monkeypatch, + str(bundle), + "--target", + "copilot", + "--trust-canvas-extensions", + ) + assert result.exit_code == 0, result.output + assert (project / ".github" / "extensions" / "widget" / "extension.mjs").exists() + + def test_canvas_blocked_when_trusted_but_flag_off(self, tmp_path, monkeypatch): + """Trust alone is not enough: the experimental flag must also be on.""" + import apm_cli.config as _conf + + monkeypatch.setattr(_conf, "_config_cache", {"experimental": {}}) + bundle = _make_bundle( + tmp_path, + files={ + "agents/a.md": "# Agent\n", + "extensions/widget/extension.mjs": "export default {};\n", + }, + ) + project = _make_project(tmp_path) + result = _invoke( + project, + monkeypatch, + str(bundle), + "--target", + "copilot", + "--trust-canvas-extensions", + ) + assert result.exit_code == 0, result.output + assert (project / ".github" / "agents" / "a.md").exists() + assert not (project / ".github" / "extensions" / "widget").exists() + # Flag OFF mirrors the silent CanvasIntegrator no-op: the canvas type + # does not exist yet, so the bundle path must not surface a + # trust-specific warning telling the operator to pass a flag they + # already passed. + assert "trust-canvas-extensions" not in result.output diff --git a/tests/unit/integration/test_canvas_integrator.py b/tests/unit/integration/test_canvas_integrator.py new file mode 100644 index 000000000..4b87a9dbb --- /dev/null +++ b/tests/unit/integration/test_canvas_integrator.py @@ -0,0 +1,643 @@ +"""Unit tests for the experimental Copilot canvas integrator. + +Covers the two-gate model (experimental flag + dependency trust gate), +Copilot-only / project-scope restrictions, atomic per-bundle deploy, +content adoption, name validation, sync/uninstall removal, and the +bundle-path detector shared with the offline install / unpack paths. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from apm_cli.integration.canvas_integrator import ( + CanvasIntegrator, + is_canvas_bundle_path, +) +from apm_cli.integration.targets import KNOWN_TARGETS +from apm_cli.utils.diagnostics import DiagnosticCollector + +# --------------------------------------------------------------------------- +# Config injection (mirrors tests/unit/test_external_scanners.py) +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _clear_config_cache(): + from apm_cli.config import _invalidate_config_cache + + _invalidate_config_cache() + yield + _invalidate_config_cache() + + +@pytest.fixture +def enable_canvas(monkeypatch): + """Enable the ``canvas`` experimental flag for the test body.""" + import apm_cli.config as _conf + + monkeypatch.setattr(_conf, "_config_cache", {"experimental": {"canvas": True}}) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_canvas(package_root: Path, name: str = "demo", *, marker: bool = True) -> Path: + """Create ``/.apm/extensions//`` with optional marker.""" + bundle = package_root / ".apm" / "extensions" / name + bundle.mkdir(parents=True, exist_ok=True) + if marker: + (bundle / "extension.mjs").write_text(f"export default {{ name: {name!r} }};\n") + (bundle / "helper.js").write_text("export const h = 1;\n") + return bundle + + +def _pkg_info(install_path: Path): + return SimpleNamespace(install_path=str(install_path)) + + +def _copilot(): + return KNOWN_TARGETS["copilot"] + + +def _claude(): + return KNOWN_TARGETS["claude"] + + +def _deployed_rel(result, project_root: Path) -> set[str]: + return {Path(p).relative_to(project_root).as_posix() for p in result.target_paths} + + +# --------------------------------------------------------------------------- +# is_canvas_bundle_path +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "rel,expected", + [ + ("extensions/demo/extension.mjs", True), + (".github/extensions/demo/extension.mjs", True), + (".copilot/extensions/widget/a.js", True), + ("extensions/demo", True), + # Case-insensitive matching: mixed-case 'Extensions' must also match + # to prevent trust gate bypass on macOS HFS+ and Windows NTFS. + ("Extensions/demo/extension.mjs", True), + (".github/Extensions/demo/extension.mjs", True), + ("EXTENSIONS/demo/extension.mjs", True), + ("skills/foo/extensions/bar.md", False), + ("agents/a.md", False), + ("extensionsfoo/x.js", False), + ("commands/extensions.md", False), + ], +) +def test_is_canvas_bundle_path(rel: str, expected: bool): + assert is_canvas_bundle_path(rel) is expected + + +# --------------------------------------------------------------------------- +# find_canvas_bundles +# --------------------------------------------------------------------------- + + +def test_find_canvas_bundles_requires_marker(tmp_path: Path): + _make_canvas(tmp_path, "withmarker") + # A directory without the marker is ignored. + (tmp_path / ".apm" / "extensions" / "nomarker").mkdir(parents=True) + bundles = CanvasIntegrator.find_canvas_bundles(tmp_path) + assert [b.name for b in bundles] == ["withmarker"] + + +def test_find_canvas_bundles_rejects_symlinked_dir(tmp_path: Path): + real = _make_canvas(tmp_path, "real") + link = tmp_path / ".apm" / "extensions" / "linked" + try: + link.symlink_to(real, target_is_directory=True) + except (OSError, NotImplementedError): + pytest.skip("platform does not support symlinks") + bundles = CanvasIntegrator.find_canvas_bundles(tmp_path) + assert [b.name for b in bundles] == ["real"] + + +def test_find_canvas_bundles_empty_when_absent(tmp_path: Path): + assert CanvasIntegrator.find_canvas_bundles(tmp_path) == [] + + +# --------------------------------------------------------------------------- +# Name validation +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("bad", ["con", "PRN", "nul", "lpt1", ".hidden", "trailing.", "bad/name"]) +def test_validate_canvas_name_rejects(bad: str): + with pytest.raises((ValueError,)): + CanvasIntegrator._validate_canvas_name(bad) + + +@pytest.mark.parametrize("ok", ["demo", "my-canvas", "a_b.c", "Widget1"]) +def test_validate_canvas_name_accepts(ok: str): + CanvasIntegrator._validate_canvas_name(ok) + + +# --------------------------------------------------------------------------- +# Flag gating +# --------------------------------------------------------------------------- + + +def test_flag_off_is_noop(tmp_path: Path): + pkg = tmp_path / "pkg" + _make_canvas(pkg) + project = tmp_path / "proj" + project.mkdir() + # No enable_canvas fixture -> flag defaults off. + result = CanvasIntegrator().integrate_canvases_for_target( + _copilot(), + _pkg_info(pkg), + project, + is_first_party=True, + ) + assert result.files_integrated == 0 + assert not (project / ".github" / "extensions").exists() + + +# --------------------------------------------------------------------------- +# First-party deploy +# --------------------------------------------------------------------------- + + +def test_first_party_deploys(tmp_path: Path, enable_canvas): + pkg = tmp_path / "pkg" + _make_canvas(pkg, "demo") + project = tmp_path / "proj" + project.mkdir() + result = CanvasIntegrator().integrate_canvases_for_target( + _copilot(), + _pkg_info(pkg), + project, + is_first_party=True, + ) + assert result.files_integrated == 1 + rels = _deployed_rel(result, project) + assert ".github/extensions/demo/extension.mjs" in rels + assert ".github/extensions/demo/helper.js" in rels + assert (project / ".github" / "extensions" / "demo" / "extension.mjs").is_file() + + +# --------------------------------------------------------------------------- +# Dependency trust gate +# --------------------------------------------------------------------------- + + +def test_dependency_blocked_without_trust(tmp_path: Path, enable_canvas): + pkg = tmp_path / "pkg" + _make_canvas(pkg, "widget") + project = tmp_path / "proj" + project.mkdir() + diags = DiagnosticCollector() + result = CanvasIntegrator().integrate_canvases_for_target( + _copilot(), + _pkg_info(pkg), + project, + diagnostics=diags, + trust_canvas=False, + is_first_party=False, + package_name="acme/widgets", + ) + assert result.files_integrated == 0 + assert not (project / ".github" / "extensions").exists() + messages = " ".join(d.message for d in diags._diagnostics) + assert "widget" in messages + assert "--trust-canvas-extensions" in messages + + +def test_dependency_deploys_with_trust(tmp_path: Path, enable_canvas): + pkg = tmp_path / "pkg" + _make_canvas(pkg, "widget") + project = tmp_path / "proj" + project.mkdir() + result = CanvasIntegrator().integrate_canvases_for_target( + _copilot(), + _pkg_info(pkg), + project, + trust_canvas=True, + is_first_party=False, + package_name="acme/widgets", + ) + assert result.files_integrated == 1 + assert (project / ".github" / "extensions" / "widget" / "extension.mjs").is_file() + + +# --------------------------------------------------------------------------- +# Target / scope restrictions +# --------------------------------------------------------------------------- + + +def test_non_copilot_target_is_noop(tmp_path: Path, enable_canvas): + pkg = tmp_path / "pkg" + _make_canvas(pkg) + project = tmp_path / "proj" + project.mkdir() + result = CanvasIntegrator().integrate_canvases_for_target( + _claude(), + _pkg_info(pkg), + project, + is_first_party=True, + ) + assert result.files_integrated == 0 + assert not (project / ".claude" / "extensions").exists() + + +def test_user_scope_first_party_blocked(tmp_path: Path, enable_canvas, monkeypatch): + """First-party canvases are refused at user scope (untracked -> would leak).""" + from apm_cli.core.scope import InstallScope + + monkeypatch.delenv("COPILOT_HOME", raising=False) + pkg = tmp_path / "pkg" + _make_canvas(pkg) + home = tmp_path / "home" + home.mkdir() + diags = DiagnosticCollector() + result = CanvasIntegrator().integrate_canvases_for_target( + _copilot().for_scope(user_scope=True), + _pkg_info(pkg), + home, + scope=InstallScope.USER, + is_first_party=True, + trust_canvas=True, + diagnostics=diags, + ) + assert result.files_integrated == 0 + assert not (home / ".copilot" / "extensions").exists() + messages = " ".join(d.message for d in diags._diagnostics) + assert "first-party" in messages + + +def test_user_scope_dependency_deploys_with_trust(tmp_path: Path, enable_canvas, monkeypatch): + """A dependency canvas deploys to ~/.copilot/extensions at user scope.""" + from apm_cli.core.scope import InstallScope + + monkeypatch.delenv("COPILOT_HOME", raising=False) + pkg = tmp_path / "pkg" + _make_canvas(pkg, "widget") + home = tmp_path / "home" + home.mkdir() + result = CanvasIntegrator().integrate_canvases_for_target( + _copilot().for_scope(user_scope=True), + _pkg_info(pkg), + home, + scope=InstallScope.USER, + is_first_party=False, + trust_canvas=True, + package_name="acme/widgets", + ) + assert result.files_integrated == 1 + assert (home / ".copilot" / "extensions" / "widget" / "extension.mjs").is_file() + assert _deployed_rel(result, home) == { + ".copilot/extensions/widget/extension.mjs", + ".copilot/extensions/widget/helper.js", + } + + +def test_user_scope_dependency_requires_trust(tmp_path: Path, enable_canvas, monkeypatch): + """A dependency canvas at user scope is blocked without the trust flag.""" + from apm_cli.core.scope import InstallScope + + monkeypatch.delenv("COPILOT_HOME", raising=False) + pkg = tmp_path / "pkg" + _make_canvas(pkg, "widget") + home = tmp_path / "home" + home.mkdir() + diags = DiagnosticCollector() + result = CanvasIntegrator().integrate_canvases_for_target( + _copilot().for_scope(user_scope=True), + _pkg_info(pkg), + home, + scope=InstallScope.USER, + is_first_party=False, + trust_canvas=False, + package_name="acme/widgets", + diagnostics=diags, + ) + assert result.files_integrated == 0 + assert not (home / ".copilot" / "extensions").exists() + messages = " ".join(d.message for d in diags._diagnostics) + assert "--trust-canvas-extensions" in messages + + +def test_user_scope_nondefault_copilot_home_blocked(tmp_path: Path, enable_canvas, monkeypatch): + """A non-default $COPILOT_HOME refuses global canvas install.""" + from apm_cli.core.scope import InstallScope + + monkeypatch.setenv("COPILOT_HOME", str(tmp_path / "custom-copilot")) + pkg = tmp_path / "pkg" + _make_canvas(pkg, "widget") + home = tmp_path / "home" + home.mkdir() + diags = DiagnosticCollector() + result = CanvasIntegrator().integrate_canvases_for_target( + _copilot().for_scope(user_scope=True), + _pkg_info(pkg), + home, + scope=InstallScope.USER, + is_first_party=False, + trust_canvas=True, + package_name="acme/widgets", + diagnostics=diags, + ) + assert result.files_integrated == 0 + messages = " ".join(d.message for d in diags._diagnostics) + assert "COPILOT_HOME" in messages + + +def test_user_scope_sync_prunes_dependency_canvas(tmp_path: Path, enable_canvas, monkeypatch): + """Uninstall sync removes a user-scope canvas via the lockfile path bucket.""" + from apm_cli.core.scope import InstallScope + + monkeypatch.delenv("COPILOT_HOME", raising=False) + pkg = tmp_path / "pkg" + _make_canvas(pkg, "widget") + home = tmp_path / "home" + home.mkdir() + user_target = _copilot().for_scope(user_scope=True) + result = CanvasIntegrator().integrate_canvases_for_target( + user_target, + _pkg_info(pkg), + home, + scope=InstallScope.USER, + is_first_party=False, + trust_canvas=True, + package_name="acme/widgets", + ) + managed = {Path(p).relative_to(home).as_posix() for p in result.target_paths} + assert managed # sanity: something was deployed + stats = CanvasIntegrator().sync_for_target(user_target, None, home, managed_files=managed) + assert stats["files_removed"] == len(managed) + assert not (home / ".copilot" / "extensions" / "widget").exists() + + +# --------------------------------------------------------------------------- +# Atomic collision + adoption +# --------------------------------------------------------------------------- + + +def test_unmanaged_collision_skips_whole_bundle(tmp_path: Path, enable_canvas): + pkg = tmp_path / "pkg" + _make_canvas(pkg, "demo") + project = tmp_path / "proj" + dest_dir = project / ".github" / "extensions" / "demo" + dest_dir.mkdir(parents=True) + # Pre-existing, unmanaged, divergent file at one bundle path. + (dest_dir / "helper.js").write_text("local edit\n") + diags = DiagnosticCollector() + result = CanvasIntegrator().integrate_canvases_for_target( + _copilot(), + _pkg_info(pkg), + project, + diagnostics=diags, + is_first_party=True, + ) + assert result.files_integrated == 0 + assert result.files_skipped == 1 + # The whole bundle is skipped: the marker is never written. + assert not (dest_dir / "extension.mjs").exists() + # The local edit is preserved. + assert (dest_dir / "helper.js").read_text() == "local edit\n" + + +def test_force_overwrites_collision(tmp_path: Path, enable_canvas): + pkg = tmp_path / "pkg" + _make_canvas(pkg, "demo") + project = tmp_path / "proj" + dest_dir = project / ".github" / "extensions" / "demo" + dest_dir.mkdir(parents=True) + (dest_dir / "helper.js").write_text("local edit\n") + result = CanvasIntegrator().integrate_canvases_for_target( + _copilot(), + _pkg_info(pkg), + project, + force=True, + is_first_party=True, + ) + assert result.files_integrated == 1 + assert (dest_dir / "extension.mjs").is_file() + assert (dest_dir / "helper.js").read_text() == "export const h = 1;\n" + + +def test_byte_identical_reinstall_is_adopted(tmp_path: Path, enable_canvas): + pkg = tmp_path / "pkg" + _make_canvas(pkg, "demo") + project = tmp_path / "proj" + project.mkdir() + integrator = CanvasIntegrator() + first = integrator.integrate_canvases_for_target( + _copilot(), _pkg_info(pkg), project, is_first_party=True + ) + assert first.files_integrated == 1 + managed = {Path(p).relative_to(project).as_posix() for p in first.target_paths} + second = integrator.integrate_canvases_for_target( + _copilot(), + _pkg_info(pkg), + project, + managed_files=managed, + is_first_party=True, + ) + assert second.files_integrated == 0 + assert second.files_adopted == 1 + + +# --------------------------------------------------------------------------- +# Sync / uninstall +# --------------------------------------------------------------------------- + + +def test_sync_removes_canvas_and_empty_dirs(tmp_path: Path, enable_canvas): + pkg = tmp_path / "pkg" + _make_canvas(pkg, "demo") + project = tmp_path / "proj" + project.mkdir() + integrator = CanvasIntegrator() + result = integrator.integrate_canvases_for_target( + _copilot(), _pkg_info(pkg), project, is_first_party=True + ) + managed = {Path(p).relative_to(project).as_posix() for p in result.target_paths} + assert (project / ".github" / "extensions" / "demo" / "extension.mjs").is_file() + + stats = integrator.sync_for_target( + _copilot(), SimpleNamespace(), project, managed_files=managed + ) + assert stats["files_removed"] >= 2 + assert not (project / ".github" / "extensions" / "demo").exists() + + +def test_sync_not_gated_by_flag(tmp_path: Path): + """Uninstall must work even when the experimental flag is off.""" + pkg = tmp_path / "pkg" + _make_canvas(pkg, "demo") + project = tmp_path / "proj" + dest = project / ".github" / "extensions" / "demo" + dest.mkdir(parents=True) + (dest / "extension.mjs").write_text("export default {};\n") + managed = {".github/extensions/demo/extension.mjs"} + stats = CanvasIntegrator().sync_for_target( + _copilot(), SimpleNamespace(), project, managed_files=managed + ) + assert stats["files_removed"] == 1 + assert not dest.exists() + + +# --------------------------------------------------------------------------- +# Two-canvas uninstall survival (only the removed package's canvas goes) +# --------------------------------------------------------------------------- + + +def test_sync_only_removes_managed_subset(tmp_path: Path, enable_canvas): + project = tmp_path / "proj" + # Two canvases deployed from two different packages. + for name in ("alpha", "beta"): + pkg = tmp_path / f"pkg_{name}" + _make_canvas(pkg, name) + CanvasIntegrator().integrate_canvases_for_target( + _copilot(), _pkg_info(pkg), project, is_first_party=True + ) + assert (project / ".github" / "extensions" / "alpha" / "extension.mjs").is_file() + assert (project / ".github" / "extensions" / "beta" / "extension.mjs").is_file() + + # Uninstall only alpha. + alpha_managed = { + os.path.relpath(str(p), str(project)).replace(os.sep, "/") + for p in (project / ".github" / "extensions" / "alpha").rglob("*") + if p.is_file() + } + CanvasIntegrator().sync_for_target( + _copilot(), SimpleNamespace(), project, managed_files=alpha_managed + ) + assert not (project / ".github" / "extensions" / "alpha").exists() + # beta survives. + assert (project / ".github" / "extensions" / "beta" / "extension.mjs").is_file() + + +# --------------------------------------------------------------------------- +# Dispatch first-party signal (regression for the package-name spoof) +# --------------------------------------------------------------------------- + + +def _canvas_only_bundle(): + """An IntegratorBundle wiring canvas + a real (no-op) skill integrator. + + ``integrate_package_primitives`` always invokes the skill integrator + outside the dispatch loop, so a real ``SkillIntegrator`` is supplied + (the test package carries no skills, so it is a no-op). The remaining + integrators are ``None`` -- the dispatch loop skips them. + """ + from apm_cli.install.services import IntegratorBundle + from apm_cli.integration.skill_integrator import SkillIntegrator + + return IntegratorBundle( + prompt=None, + agent=None, + skill=SkillIntegrator(), + instruction=None, + command=None, + hook=None, + canvas=CanvasIntegrator(), + ) + + +def _dispatch_pkg_info(install_path: Path): + """A package_info rich enough for the full dispatch (skill branch inert).""" + from apm_cli.models.apm_package import PackageType + + return SimpleNamespace( + install_path=Path(install_path), + package_type=PackageType.APM_PACKAGE, + package=SimpleNamespace(name="dep"), + dependency_ref=None, + ) + + +def test_dispatch_dependency_named_local_is_not_first_party(tmp_path: Path, enable_canvas): + """A dependency literally named '_local' must still hit the trust gate. + + First-party status is decided by the call path (the ``is_first_party`` + kwarg), never inferred from the package name, so an attacker cannot + bypass the trust gate by naming their package ``_local``. + """ + from apm_cli.install.services import integrate_package_primitives + + _make_canvas(tmp_path, "widget") + project_root = tmp_path / "proj" + project_root.mkdir() + diags = DiagnosticCollector() + + result = integrate_package_primitives( + _dispatch_pkg_info(tmp_path), + project_root, + targets=[_copilot()], + integrators=_canvas_only_bundle(), + force=False, + managed_files=set(), + diagnostics=diags, + package_name="_local", + ctx=SimpleNamespace(trust_canvas=False, verbose=False), + # is_first_party defaults to False (dependency call path) + ) + + assert result["canvases"] == 0 + assert not (project_root / ".github" / "extensions" / "widget").exists() + + +def test_dispatch_first_party_flag_deploys(tmp_path: Path, enable_canvas): + """The local-content call path passes is_first_party=True and deploys.""" + from apm_cli.install.services import integrate_package_primitives + + _make_canvas(tmp_path, "widget") + project_root = tmp_path / "proj" + project_root.mkdir() + diags = DiagnosticCollector() + + result = integrate_package_primitives( + _dispatch_pkg_info(tmp_path), + project_root, + targets=[_copilot()], + integrators=_canvas_only_bundle(), + force=False, + managed_files=set(), + diagnostics=diags, + package_name="owner/dep", + ctx=SimpleNamespace(trust_canvas=False, verbose=False), + is_first_party=True, + ) + + assert result["canvases"] == 1 + assert (project_root / ".github" / "extensions" / "widget" / "extension.mjs").is_file() + + +def test_dispatch_dependency_deploys_with_trust(tmp_path: Path, enable_canvas): + """A dependency canvas deploys when the operator trusts canvas extensions.""" + from apm_cli.install.services import integrate_package_primitives + + _make_canvas(tmp_path, "widget") + project_root = tmp_path / "proj" + project_root.mkdir() + diags = DiagnosticCollector() + + result = integrate_package_primitives( + _dispatch_pkg_info(tmp_path), + project_root, + targets=[_copilot()], + integrators=_canvas_only_bundle(), + force=False, + managed_files=set(), + diagnostics=diags, + package_name="owner/dep", + ctx=SimpleNamespace(trust_canvas=True, verbose=False), + ) + + assert result["canvases"] == 1 + assert (project_root / ".github" / "extensions" / "widget" / "extension.mjs").is_file() diff --git a/tests/unit/integration/test_data_driven_dispatch.py b/tests/unit/integration/test_data_driven_dispatch.py index f84bea922..bc25c0d75 100644 --- a/tests/unit/integration/test_data_driven_dispatch.py +++ b/tests/unit/integration/test_data_driven_dispatch.py @@ -266,8 +266,8 @@ def test_all_targets_dispatches_all_primitives(self): # Verify every non-skills primitive in each target was dispatched for target in all_targets: for prim_name in target.primitives: - if prim_name == "skills": - continue # skills handled separately + if prim_name in ("skills", "canvas"): + continue # skills + canvas (copilot-only, experimental) handled separately assert (target.name, prim_name) in dispatched, ( f"Expected ({target.name}, {prim_name}) to be dispatched" ) @@ -324,6 +324,7 @@ def test_partition_parity_with_old_buckets(self): "skills", # cross-target bucket "hooks", # cross-target bucket "prompts_copilot-app", # copilot-app uses dedicated prompts bucket + "canvas_copilot", # canvas extensions (copilot-only, experimental) } assert expected_keys == set(buckets.keys()), ( @@ -858,6 +859,7 @@ def test_special_cases_excluded(self): "agents", "commands", "instructions", + "canvas", } # skills and hooks are special-cased check_primitive_coverage( @@ -911,7 +913,15 @@ def test_dispatch_counter_keys_match_result_dict(self): from apm_cli.integration.dispatch import get_dispatch_table dispatch = get_dispatch_table() - expected_counters = {"prompts", "agents", "commands", "instructions", "hooks", "skills"} + expected_counters = { + "prompts", + "agents", + "commands", + "instructions", + "hooks", + "skills", + "canvases", + } actual_counters = {entry.counter_key for entry in dispatch.values()} assert actual_counters == expected_counters @@ -946,6 +956,7 @@ def test_dead_dispatch_entry_raises(self): "instructions": None, "hooks": None, "skills": None, + "canvas": None, "phantoms": None, # not in any KNOWN_TARGETS } with pytest.raises(RuntimeError, match="phantoms"): diff --git a/tests/unit/test_audit_scan_and_render.py b/tests/unit/test_audit_scan_and_render.py index 191a3050f..a39637749 100644 --- a/tests/unit/test_audit_scan_and_render.py +++ b/tests/unit/test_audit_scan_and_render.py @@ -666,3 +666,88 @@ def test_findings_title_mixed_includes_counts(self): assert "Audit Findings" in title assert "apm: 1" in title assert "skillspector: 1" in title + + +class TestDeployedCanvasBundles: + """_deployed_canvas_bundles derives canvas roots from lockfile entries.""" + + def _lock(self, deployed): + lock = MagicMock() + dep = MagicMock() + dep.deployed_files = deployed + lock.dependencies = {"owner/repo": dep} + return lock + + def test_user_scope_bundle_root(self, tmp_path): + from apm_cli.commands import audit as audit_mod + + lock = self._lock([".copilot/extensions/widget/extension.mjs"]) + with ( + patch.object(audit_mod, "get_lockfile_path", return_value=tmp_path / "x"), + patch("apm_cli.deps.lockfile.LockFile.read", return_value=lock), + ): + roots = audit_mod._deployed_canvas_bundles(tmp_path, None) + assert roots == [".copilot/extensions/widget"] + + def test_project_scope_and_extra_files_dedupe(self, tmp_path): + from apm_cli.commands import audit as audit_mod + + lock = self._lock( + [ + ".github/extensions/widget/extension.mjs", + ".github/extensions/widget/assets/app.js", + ".github/instructions/foo.md", + ] + ) + with ( + patch.object(audit_mod, "get_lockfile_path", return_value=tmp_path / "x"), + patch("apm_cli.deps.lockfile.LockFile.read", return_value=lock), + ): + roots = audit_mod._deployed_canvas_bundles(tmp_path, None) + assert roots == [".github/extensions/widget"] + + def test_no_lockfile_returns_empty(self, tmp_path): + from apm_cli.commands import audit as audit_mod + + with ( + patch.object(audit_mod, "get_lockfile_path", return_value=tmp_path / "x"), + patch("apm_cli.deps.lockfile.LockFile.read", return_value=None), + ): + assert audit_mod._deployed_canvas_bundles(tmp_path, None) == [] + + def test_package_filter_excludes_other_dep(self, tmp_path): + from apm_cli.commands import audit as audit_mod + + lock = self._lock([".copilot/extensions/widget/extension.mjs"]) + with ( + patch.object(audit_mod, "get_lockfile_path", return_value=tmp_path / "x"), + patch("apm_cli.deps.lockfile.LockFile.read", return_value=lock), + ): + roots = audit_mod._deployed_canvas_bundles(tmp_path, "other/dep") + assert roots == [] + + +class TestRenderCanvasNote: + """_render_canvas_note surfaces an info line per deployed canvas.""" + + def test_emits_info_when_bundles_present(self, tmp_path, logger): + from apm_cli.commands import audit as audit_mod + + with patch.object( + audit_mod, + "_deployed_canvas_bundles", + return_value=[".copilot/extensions/widget"], + ): + log = MagicMock() + audit_mod._render_canvas_note(tmp_path, None, log) + joined = " ".join(str(c.args[0]) for c in log.info.call_args_list) + assert "executable canvas extension" in joined + assert ".copilot/extensions/widget" in joined + + def test_silent_when_no_bundles(self, tmp_path): + from apm_cli.commands import audit as audit_mod + + with patch.object(audit_mod, "_deployed_canvas_bundles", return_value=[]): + log = MagicMock() + audit_mod._render_canvas_note(tmp_path, None, log) + log.info.assert_not_called() diff --git a/tests/unit/test_models_validation_rules.py b/tests/unit/test_models_validation_rules.py index 265ef5084..b891daaa6 100644 --- a/tests/unit/test_models_validation_rules.py +++ b/tests/unit/test_models_validation_rules.py @@ -795,3 +795,35 @@ def test_empty_primitive_file_warns(self, tmp_path: Path) -> None: (instructions_dir / "empty.md").write_text("") result: ValidationResult = validate_apm_package(tmp_path) assert any("Empty primitive" in w for w in result.warnings) + + def test_canvas_only_package_is_valid(self, tmp_path: Path) -> None: + """A package whose only primitive is a canvas is not flagged empty.""" + (tmp_path / "apm.yml").write_text("name: pkg\nversion: 1.0.0\n") + bundle: Path = tmp_path / ".apm" / "extensions" / "widget" + bundle.mkdir(parents=True) + (bundle / "extension.mjs").write_text("export default {}\n") + result: ValidationResult = validate_apm_package(tmp_path) + assert result.is_valid + assert not any("No primitive files" in w for w in result.warnings) + + def test_canvas_emits_gated_executable_warning(self, tmp_path: Path) -> None: + """A canvas bundle earns an explicit executable/gated warning.""" + (tmp_path / "apm.yml").write_text("name: pkg\nversion: 1.0.0\n") + bundle: Path = tmp_path / ".apm" / "extensions" / "widget" + bundle.mkdir(parents=True) + (bundle / "extension.mjs").write_text("export default {}\n") + result: ValidationResult = validate_apm_package(tmp_path) + canvas_warns = [w for w in result.warnings if "Canvas extension" in w] + assert len(canvas_warns) == 1 + assert "widget" in canvas_warns[0] + assert "--trust-canvas-extensions" in canvas_warns[0] + + def test_directory_without_marker_is_not_canvas(self, tmp_path: Path) -> None: + """An extensions/ subdir lacking extension.mjs is not a canvas bundle.""" + (tmp_path / "apm.yml").write_text("name: pkg\nversion: 1.0.0\n") + bundle: Path = tmp_path / ".apm" / "extensions" / "notcanvas" + bundle.mkdir(parents=True) + (bundle / "readme.txt").write_text("not a canvas") + result: ValidationResult = validate_apm_package(tmp_path) + assert not any("Canvas extension" in w for w in result.warnings) + assert any("No primitive files" in w for w in result.warnings) diff --git a/tests/unit/test_unpacker.py b/tests/unit/test_unpacker.py index 79ae41914..6063ba730 100644 --- a/tests/unit/test_unpacker.py +++ b/tests/unit/test_unpacker.py @@ -535,6 +535,80 @@ def test_unpack_cmd_multi_dep_logging(self, tmp_path): assert "Unpacked 2 file(s)" in result.output +class TestUnpackCanvasTrust: + """Canvas extensions are executable code: unpack must enforce both gates. + + Two independent gates must BOTH hold before a canvas unpacks: the + ``canvas`` experimental flag (feature availability) and ``trust_canvas`` + (executable-code trust). Missing either one blocks the canvas. + """ + + @pytest.fixture(autouse=True) + def _clear_config_cache(self): + from apm_cli.config import _invalidate_config_cache + + _invalidate_config_cache() + yield + _invalidate_config_cache() + + @pytest.fixture + def enable_canvas(self, monkeypatch): + import apm_cli.config as _conf + + monkeypatch.setattr(_conf, "_config_cache", {"experimental": {"canvas": True}}) + + def test_canvas_blocked_by_default(self, tmp_path): + deployed = [".github/agents/a.md", ".github/extensions/widget/extension.mjs"] + bundle = _build_bundle_dir(tmp_path, deployed) + output = tmp_path / "target" + output.mkdir() + + result = unpack_bundle(bundle, output) + + assert result.canvas_blocked == 1 + assert ".github/extensions/widget/extension.mjs" not in result.files + assert ".github/agents/a.md" in result.files + assert not (output / ".github" / "extensions" / "widget").exists() + assert (output / ".github" / "agents" / "a.md").exists() + + def test_canvas_deployed_with_trust_and_flag(self, tmp_path, enable_canvas): + deployed = [".github/agents/a.md", ".github/extensions/widget/extension.mjs"] + bundle = _build_bundle_dir(tmp_path, deployed) + output = tmp_path / "target" + output.mkdir() + + result = unpack_bundle(bundle, output, trust_canvas=True) + + assert result.canvas_blocked == 0 + assert ".github/extensions/widget/extension.mjs" in result.files + assert (output / ".github" / "extensions" / "widget" / "extension.mjs").exists() + + def test_canvas_blocked_when_trusted_but_flag_off(self, tmp_path): + """Trust alone is not enough: the experimental flag must also be on.""" + deployed = [".github/extensions/widget/extension.mjs"] + bundle = _build_bundle_dir(tmp_path, deployed) + output = tmp_path / "target" + output.mkdir() + + result = unpack_bundle(bundle, output, trust_canvas=True) + + assert result.canvas_blocked == 1 + assert ".github/extensions/widget/extension.mjs" not in result.files + assert not (output / ".github" / "extensions").exists() + + def test_canvas_block_is_dry_run_visible(self, tmp_path): + deployed = [".github/extensions/widget/extension.mjs"] + bundle = _build_bundle_dir(tmp_path, deployed) + output = tmp_path / "target" + output.mkdir() + + result = unpack_bundle(bundle, output, dry_run=True) + + assert result.canvas_blocked == 1 + assert result.files == [] + assert not (output / ".github" / "extensions").exists() + + # --------------------------------------------------------------------------- # ZIP extraction security tests (mirrors the tar.gz equivalents in # tests/integration/test_wave4_pure_logic_coverage.py)