Skip to content

harden(executables): expand allowExecutables gate to mcp and canvas; store approvals user-local#1865

Open
sergio-sisternes-epam wants to merge 7 commits into
mainfrom
sergio-sisternes-epam-feat-expand-executables-gate-mcp-canvas
Open

harden(executables): expand allowExecutables gate to mcp and canvas; store approvals user-local#1865
sergio-sisternes-epam wants to merge 7 commits into
mainfrom
sergio-sisternes-epam-feat-expand-executables-gate-mcp-canvas

Conversation

@sergio-sisternes-epam

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

Copy link
Copy Markdown
Collaborator

harden(executables): expand allowExecutables gate to mcp and canvas; store approvals in user-local file

TL;DR

The allowExecutables default-deny gate (introduced in #1723) previously
enforced only hooks and bin, leaving MCP server config writes and canvas
deploys outside its control. This PR promotes mcp and canvas to first-class
enforced types and removes --trust-canvas-extensions, unifying all four
executable surfaces under a single approval model. It also fixes a supply-chain
security threat: apm approve previously wrote grants into apm.yml (committed),
meaning anyone cloning a project inherited all executable trusts automatically.
Approvals now live in ~/.apm/approvals.yml — user-local, never committed.

Important

Breaking changes:
--trust-canvas-extensions is removed. Consumers of dependency-provided canvases
must add an allowExecutables: {} block to apm.yml and run apm approve <pkg>.

Existing allowExecutables entries in apm.yml still work for CI pipelines.
Personal approvals from interactive apm approve sessions now go to ~/.apm/approvals.yml.

Problem (WHY)

  • MCP was tracked but not enforced. security/executables.py marked MCP as
    "Reserved for future enforcement" — a self-described gap. A dependency could write
    an MCP config entry without any approval step.
  • --trust-canvas-extensions was inconsistent with the gate model. The flag
    was a one-shot CLI opt-in that bypassed the allowExecutables table, so canvas
    trust was invisible to apm deny and to any future policy enforcement.
  • [!] Defence-in-depth gap: a dep named _local. check_executable_approval
    short-circuited for package_name == "_local" before checking is_first_party,
    meaning a malicious dependency named _local with is_first_party=False would
    bypass the canvas gate via the name shortcut alone.
  • [!] apm approve wrote to project apm.yml — a supply-chain security threat.
    Committed approvals mean any developer cloning the repo inherits all executable
    trusts automatically. One developer's apm approve <malicious-pkg> would silently
    grant execution rights across the entire team.

The approval gate was designed to deliver
"No arbitrary code execution"
for all executable surfaces — MCP server processes and canvas Node.js bundles
both qualify and both need the same governance; and approval must remain personal.

Approach (WHAT)

# Change
1 Add EXEC_TYPE_CANVAS; move EXEC_TYPE_MCP from "tracked" to ENFORCED_EXEC_TYPES — four types, one gate
2 check_executable_approval returns 4-tuple (hooks_ok, bin_ok, mcp_ok, canvas_ok)
3 Canvas gate re-derives approval via is_package_approved independently of _local name shortcut
4 MCP gate added after collect_transitive in commands/install.py
5 Remove --trust-canvas-extensions / trust_canvas from all CLI options, dataclasses, and pipeline kwargs
6 apm approve writes to ~/.apm/approvals.yml (user-local); effective_allow_executables() merges project gate signal + user approvals at install time

Implementation (HOW)

  • security/executables.pyEXEC_TYPE_CANVAS added; ENFORCED_EXEC_TYPES holds all four; ExecutableDeclaration gains canvas_count/canvas_details; scanner detects .apm/extensions/*/extension.mjs; new helpers get_user_approvals_path, load_user_approvals, save_user_approvals, effective_allow_executables.
  • install/exec_gate.pycheck_executable_approval expanded to 4-tuple.
  • install/services.py — 4-tuple destructured; canvas gate uses is_first_party + explicit is_package_approved call (bypasses _local name shortcut for defence-in-depth).
  • install/service.py — removed stale trust_canvas=request.trust_canvas kwarg.
  • commands/install.py--trust-canvas-extensions and InstallOptions.trust_canvas removed; MCP gate inserted after collect_transitive; MCP lookup uses effective_allow_executables().
  • install/context.py, request.py, pipeline.pytrust_canvas field and param removed throughout.
  • commands/approve.py_load_allow_executables() reads from ~/.apm/approvals.yml via get_user_approvals_path(); _approve_all_pending / _approve_packages write via save_user_approvals(); deny_cmd no longer requires project manifest.
  • install/phases/integrate.py — interactive approval prompt seeds from load_user_approvals() and persists via save_user_approvals().
  • install/template.py_effective_allow(ctx) helper merges project gate signal with user approvals.
  • Docs (12 files)approve.md, canvas.md, security.md, primitives-and-targets.md, install.md, unpack.md, experimental.md, governance.md, package-authoring.md, commands.md, apm-contributor-dashboard/README.md updated.
  • Tests (11 files)test_executables.py, test_canvas_integrator.py, test_install_local_bundle.py, test_unpacker.py, test_models_validation_rules.py, test_install_context.py, test_approve_deny.py updated.

Diagrams

Gate chain after this PR — new nodes (dashed) show the MCP and canvas gates added to the existing hooks gate.

flowchart LR
    A["scan_package_executables"] --> B["4-tuple result"]:::new
    B --> C{"hooks blocked?"}
    B --> D{"mcp blocked?"}:::new
    B --> E{"canvas blocked?"}:::new
    C -->|yes| F["skip + warn apm approve"]
    C -->|no| G["deploy hooks"]
    D -->|yes| H["skip MCP config write"]:::new
    D -->|no| I["deploy MCP config"]
    E -->|yes| J["skip canvas deploy"]:::new
    E -->|no| K["deploy .github/extensions/"]
    classDef new stroke-dasharray: 5 5;
    class B,D,E,H,J new;
Loading

Approval storage split — project file enables the gate and holds CI grants; user-local file holds personal approvals.

flowchart LR
    P["apm.yml\n(allowExecutables: {})"]
    U["~/.apm/approvals.yml\n(user-local, not committed)"]
    E["effective_allow_executables()"]
    I["apm install\nexecutable gate"]
    P -->|"gate signal + CI grants"| E
    U -->|"personal approvals"| E
    E -->|"merged approvals"| I
Loading

Trade-offs

  • --trust-canvas-extensions removed (not deprecated). Breaking change; callers must migrate to allowExecutables + apm approve.
  • apm approve --all now writes only to ~/.apm/approvals.yml. The old CI pattern of apm approve --all && git add apm.yml no longer works automatically. CI pipelines must either commit grants directly in apm.yml or run apm approve in a step that has write access to the runner's home directory. Documented in approve.md.
  • deny_cmd no longer requires project manifest. apm deny can now be run from any directory. This is intentional — revoking a trust grant should not depend on having the project checked out.
  • apm unpack (deprecated) canvas gate simplified. Canvas deploys freely when the canvas feature flag is ON in apm unpack, since the deprecated command has no project context for allowExecutables lookup. Narrow surface, accepted regression.

Benefits

  1. All four executable surfaces (hooks, bin, mcp, canvas) governed by one allowExecutables table — one apm approve, one audit trail.
  2. Cloning a project with allowExecutables: {} no longer auto-grants any executable trust; each developer must explicitly approve packages.
  3. Dependency-named-_local spoofing of the canvas gate is closed.
  4. 17,342 unit tests pass; ruff, pylint R0801, and auth-signals lint all green.

Validation

Lint chain (all clean)
$ uv run --extra dev ruff check src/ tests/
All checks passed!
$ uv run --extra dev ruff format --check src/ tests/
1295 files already formatted
$ uv run --extra dev python -m pylint --disable=all --enable=R0801 \
    --min-similarity-lines=10 --fail-on=R0801 src/apm_cli/
Your code has been rated at 10.00/10
$ bash scripts/lint-auth-signals.sh
[+] auth-signal lint clean
Test suite (17,342 passed)
$ uv run --extra dev pytest tests/unit/ -q
17342 passed, 2 skipped, 21 xfailed, 19 warnings, 84 subtests passed in 55.64s

Scenario Evidence

# Scenario (user promise) Principle Test(s) Type
1 No allowExecutables block — gate disabled, hooks/bin/mcp/canvas deploy freely (backward compat) DevX test_canvas_deploys_when_allow_executables_none unit
2 Dependency canvas blocked when package not in approvals Secure by default test_dispatch_dependency_named_local_is_not_first_party unit
3 Dependency named _local with is_first_party=False still blocked by canvas gate Secure by default test_dispatch_dependency_named_local_is_not_first_party unit
4 apm approve <pkg> writes grant to ~/.apm/approvals.yml, NOT to apm.yml Supply-chain safety test_approve_all_writes_user_file unit
5 apm deny <pkg> removes from user file, not project file Supply-chain safety test_deny_existing_entry unit

How to test

  • Add allowExecutables: {} to a test apm.yml; run apm approve <pkg>; confirm ~/.apm/approvals.yml is updated and apm.yml is unchanged.
  • Run apm install --target copilot and confirm approved canvas deploys to .github/extensions/.
  • Remove the approval; re-run; confirm canvas is skipped and diagnostic says apm approve.
  • Confirm apm install --trust-canvas-extensions exits with "unrecognized option" (flag is gone).
  • Run uv run --extra dev pytest tests/unit/ -q — confirm 0 failures.

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

- Add EXEC_TYPE_CANVAS; all four types (hooks, bin, mcp, canvas)
  are now in ENFORCED_EXEC_TYPES
- Remove --trust-canvas-extensions / trust_canvas everywhere;
  canvas trust is now governed by allowExecutables + apm approve
- Keep --trust-transitive-mcp (orthogonal concern)
- exec_gate.check_executable_approval returns 4-tuple
  (hooks_ok, bin_ok, mcp_ok, canvas_ok)
- Canvas gate in integrate_package_primitives uses is_first_party
  and is_package_approved independently (defence-in-depth: a dep
  named _local with is_first_party=False is still blocked)
- MCP gate added in commands/install.py after collect_transitive
- apm unpack (deprecated) canvas gate simplified to feature-flag-only
- Update all tests; fix service.py (missed trust_canvas= kwarg)
- Update docs: canvas.md, security.md, primitives-and-targets.md,
  install.md, unpack.md, experimental.md, commands.md, governance.md,
  package-authoring.md, apm-contributor-dashboard README

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings June 20, 2026 13:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR expands the allowExecutables opt-in default-deny model beyond hooks and bin/ to also cover MCP server writes and Copilot canvas extensions, while removing --trust-canvas-extensions in favor of apm approve <pkg> + allowExecutables in apm.yml. It updates the install pipeline, bundle/unpack paths, validation diagnostics, tests, and documentation to reflect the unified approval model.

Changes:

  • Adds canvas as an executable type and promotes mcp to enforced executable gating (now four enforced types).
  • Removes the trust_canvas / --trust-canvas-extensions plumbing from CLI/options/context/pipeline and routes canvas trust through allowExecutables.
  • Updates bundle install (apm install <bundle>) and apm unpack behavior plus associated docs/tests and diagnostics.

Reviewed changes

Copilot reviewed 34 out of 34 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
tests/unit/test_unpacker.py Updates unpacker tests for canvas gating now being feature-flag-only in apm unpack.
tests/unit/test_models_validation_rules.py Adjusts validation-warning assertions to reference apm approve instead of the removed flag.
tests/unit/security/test_executables.py Updates expectations now that MCP is enforced and exec_types/summary include MCP.
tests/unit/integration/test_canvas_integrator.py Updates canvas integrator tests to the allowExecutables-based approval messaging and behavior.
tests/unit/install/test_install_local_bundle.py Updates local-bundle install tests for new canvas approval/enforcement semantics.
tests/unit/install/test_architecture_invariants.py Updates commands/install.py LOC budget and narrative to reflect new MCP gate and flag removal.
tests/unit/commands/test_install_phase3.py Removes trust_canvas field expectations in install context construction.
tests/unit/commands/test_install_context.py Removes trust_canvas from required fields and round-trip tests.
tests/unit/commands/test_install_context_and_resolution.py Removes trust_canvas from context defaults and resolution tests.
src/apm_cli/security/executables.py Adds EXEC_TYPE_CANVAS, enforces MCP+canvas, and scans .apm/extensions/*/extension.mjs.
src/apm_cli/models/validation.py Updates canvas validation warning text to direct users to apm approve.
src/apm_cli/integration/canvas_integrator.py Updates docs/diagnostics to allowExecutables/apm approve trust model.
src/apm_cli/install/services.py Expands approval tuple, adds canvas skip logging, and enforces canvas approval for deps with defense-in-depth against _local spoofing.
src/apm_cli/install/service.py Removes stale trust_canvas kwarg from service->pipeline call.
src/apm_cli/install/request.py Removes trust_canvas field from InstallRequest.
src/apm_cli/install/pipeline.py Removes trust_canvas param threading through the install pipeline.
src/apm_cli/install/local_bundle_handler.py Threads allow_executables into local-bundle integration path.
src/apm_cli/install/exec_gate.py Extends approval check to return (hooks, bin, mcp, canvas) and records blocked executables.
src/apm_cli/install/context.py Removes trust_canvas from InstallContext.
src/apm_cli/core/experimental.py Updates experimental-flag hint text to describe allowExecutables approval.
src/apm_cli/commands/pack.py Removes --trust-canvas-extensions from apm unpack command surface and messaging.
src/apm_cli/commands/install.py Removes --trust-canvas-extensions, adds allowExecutables read for bundle installs, and introduces MCP allowExecutables filtering.
src/apm_cli/bundle/unpacker.py Drops trust flag from unpacker; canvas is stripped only when feature flag is off.
src/apm_cli/bundle/plugin_exporter.py Updates comment to reflect approval via allowExecutables/apm approve.
packages/apm-guide/.apm/skills/apm-usage/package-authoring.md Updates authoring docs to replace the removed flag with apm approve.
packages/apm-guide/.apm/skills/apm-usage/governance.md Updates governance docs to reflect allowExecutables approval model for canvases.
packages/apm-guide/.apm/skills/apm-usage/commands.md Removes --trust-canvas-extensions from the apm install command reference row.
packages/apm-contributor-dashboard/README.md Updates installation instructions to require apm approve before install.
docs/src/content/docs/reference/experimental.md Updates canvas flag description to reference allowExecutables approval.
docs/src/content/docs/reference/cli/unpack.md Removes --trust-canvas-extensions from unpack CLI docs.
docs/src/content/docs/reference/cli/install.md Removes --trust-canvas-extensions from install CLI docs.
docs/src/content/docs/integrations/canvas.md Updates trust-gate documentation to allowExecutables + apm approve.
docs/src/content/docs/enterprise/security.md Updates security model mention of canvas trust gating.
docs/src/content/docs/concepts/primitives-and-targets.md Updates primitive matrix/canvas notes to describe allowExecutables-based approval.

Comment thread src/apm_cli/commands/install.py Outdated
Comment thread src/apm_cli/commands/install.py Outdated
Comment thread src/apm_cli/install/services.py Outdated
Comment thread docs/src/content/docs/integrations/canvas.md Outdated
Comment thread packages/apm-guide/.apm/skills/apm-usage/governance.md Outdated
Comment thread docs/src/content/docs/enterprise/security.md
…ovals.yml

Previously apm approve wrote grants to the project apm.yml, which gets
committed to source control. Anyone cloning the repo would inherit all
executable approvals automatically -- a security threat where a single
developer's approve decision silently grants trust to the whole team.

This commit separates the two concerns:
- apm.yml keeps the gate SIGNAL (allowExecutables: {}) to tell APM the
  gate is enabled; specific package grants written there are still valid
  for CI pipelines that need shared approvals.
- ~/.apm/approvals.yml is the new user-local store for interactive apm
  approve decisions. The file is never committed (not a git artifact).

Changes:
- security/executables.py: add get_user_approvals_path(), load_user_approvals(),
  save_user_approvals(), effective_allow_executables() helpers; load_user_approvals
  reads the raw mapping directly (no allowExecutables wrapper).
- commands/approve.py: _load_allow_executables() reads from user file via
  get_user_approvals_path(); _approve_all_pending / _approve_packages write via
  save_user_approvals(); deny_cmd no longer requires project context.
- install/phases/integrate.py: interactive approval prompt seeds from
  load_user_approvals() and persists via save_user_approvals() instead
  of writing back to apm.yml.
- install/template.py: _effective_allow(ctx) merges project gate signal
  with user approvals at wire-up time.
- commands/install.py: MCP gate uses effective_allow_executables() to
  merge project + user approvals.
- tests/unit/commands/test_approve_deny.py: fully rewritten; all tests
  patch get_user_approvals_path to a tmp_path file and assert grants
  land in the user file, NOT in apm.yml.
- Docs: reference/cli/approve.md, integrations/canvas.md, governance.md,
  commands.md updated to reflect the user-local store.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@sergio-sisternes-epam sergio-sisternes-epam changed the title harden(executables): expand allowExecutables gate to mcp and canvas harden(executables): expand allowExecutables gate to mcp and canvas; store approvals user-local Jun 20, 2026
sergio-sisternes-epam and others added 5 commits June 20, 2026 21:45
…uard, doc accuracy

- install.py: use _mcp_dep.name (correct MCPDependency attr) so MCP gate
  actually filters unapproved transitive MCP servers (was always no-op via
  non-existent source_package/package_id attrs)
- install.py: fail-closed on allowExecutables parse error in bundle-install
  path; was silently passing None (no enforcement) on any exception; now
  logs a warning and defaults to {} (gate enabled, nothing approved)
- services.py: wrap extensions_root.iterdir() in _log_canvas_skip() with
  try/except OSError to prevent an unreadable extensions dir from aborting
  the entire install
- canvas.md, security.md, governance.md: clarify enforcement is opt-in
  (only when allowExecutables block is present in apm.yml); was misleadingly
  stated as 'blocked by default'

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
….py below CI 2100-line limit

- Move 32-line MCP allowExecutables filter block into
  filter_mcp_by_allow_executables() in security/executables.py
- Move 20-line bundle allowExecutables read block into
  read_bundle_allow_executables() in security/executables.py
- install.py: 2139 -> 2095 lines (CI limit: 2100)
- No behaviour change; existing tests pass

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add new Section 10.13 'Executable primitive approval gate' to the
OpenAPM v0.1 spec with two consumer MUSTs:

- req-sc-009: consumer MUST fail-closed on allowExecutables gate --
  any hook, bin, MCP server, or canvas extension from a dependency
  not in the effective approval set MUST NOT be deployed.
- req-sc-010: consumer MUST persist interactive approval decisions
  user-locally (not in project apm.yml) so one developer's approval
  cannot propagate via VCS to teammates.

Updates:
- Section 10.11 summary table: rows 11 and 12
- Section 11.3.2 Consumer enumeration: req-sc-009 and req-sc-010
- Appendix C index: two new rows
- Section 1.3 + Appendix C trailer: 90 -> 92 normative statements
- Revision history: v0.1.5 entry
- Manifest: openapm-v0.1.requirements.yml (req-sc-009, req-sc-010)
- Conformance tests: two new @pytest.mark.req tests, both passing

This satisfies the Mode B spec-conformance gate requirement that
changes to critical paths (install/, integration/) carry spec citations.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Auto-generated by 'uv run --extra dev python -m tests.spec_conformance.gen_statement'.
Adds req-sc-009 and req-sc-010 entries; consumer active count 61 -> 63;
total_requirements 90 -> 92.

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

Copy link
Copy Markdown
Collaborator

Advisory review + governance decision

Hi @sergio-sisternes-epam -- this is a consolidated advisory synthesis (apm-review-panel + a 4-expert enterprise-governance panel arbitrated by the CEO persona). It is advisory: nothing here blocks merge. CI is green; the BLOCKED state is review-gating only.

Stance: ship_with_followups

The narrow security wins are strictly positive and should land: default-deny consent expanded to hooks/bin/mcp/canvas, and personal approvals moved off the committed project file. Two small must-fixes before merge, then a larger design follow-up that this PR should not be blocked on.

Must-fix before merge (CI-invisible)

  1. Stale integration test contradicts the new source. src/apm_cli/security/executables.py now sets ENFORCED_EXEC_TYPES = (HOOKS, BIN, MCP, CANVAS), but tests/integration/test_executables_gate_integration.py was not updated and still asserts the opposite:

    • line 511: assert EXEC_TYPE_MCP not in ENFORCED_EXEC_TYPES
    • line 136: assert EXEC_TYPE_MCP not in decl.exec_types

    This is invisible on this PR because ci.yml runs only tests/unit; the integration suite runs in a separate workflow. It will break the integration pipeline the moment it is next collected. Please update these assertions to match the new enforced set.

  2. Missing CHANGELOG entry. This is a user-visible hardening (and removes --trust-canvas-extensions, which is breaking). Please add a CHANGELOG line under [Unreleased], and call out the flag removal explicitly.

One design ask for this PR

  • Do not persist a new top-level ~/.apm/approvals.yml. Fold personal consent into the existing ~/.apm/config under a namespaced key. This is the single change that keeps net-new control-surface files at zero (see the design issue below for the unified vocabulary).

Two governance gaps this PR surfaces (follow-up, not blockers)

  • Gap A -- asymmetry. The policy plane (apm-policy.yml) can centrally DENY executables (bin_deploy.deny_all/deny) but has no field to GRANT or mandate them, and allowExecutables does not inherit. An admin cannot roll a required hook/MCP out to a large fleet. effective_allow_executables() reads only project apm.yml + the user file and never consults policy, with user winning on conflict.
  • Gap B -- audit false-positive. A required package whose only payload is executable is gate-stripped to empty deployed_files, so the required-packages-deployed audit (policy_checks.py:~198, if not locked.deployed_files) reports a false non-compliance. The audit should assert package presence, not deployed-executable files on disk.

Where the bigger design goes

These two gaps, plus a control-surface/vocabulary unification (allowExecutables / executables / approvals / bin_deploy collapse onto one noun), the precedence model (deny-wins; org is the ceiling on DENY; consent folds into existing config), and the scaling tooling (apm explain), are captured in a dedicated Executable Trust Governance design issue being filed now. This PR's narrow wins are independent of that work and can merge once the two must-fixes land.

Thanks for the hardening work here -- the gate expansion is the right direction.

@danielmeppiel danielmeppiel left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Must-fix before merge (CI-invisible)
Stale integration test contradicts the new source. src/apm_cli/security/executables.py now sets ENFORCED_EXEC_TYPES = (HOOKS, BIN, MCP, CANVAS), but tests/integration/test_executables_gate_integration.py was not updated and still asserts the opposite:

line 511: assert EXEC_TYPE_MCP not in ENFORCED_EXEC_TYPES
line 136: assert EXEC_TYPE_MCP not in decl.exec_types
This is invisible on this PR because ci.yml runs only tests/unit; the integration suite runs in a separate workflow. It will break the integration pipeline the moment it is next collected. Please update these assertions to match the new enforced set.

Missing CHANGELOG entry. This is a user-visible hardening (and removes --trust-canvas-extensions, which is breaking). Please add a CHANGELOG line under [Unreleased], and call out the flag removal explicitly.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants