diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a517122c..919c74eae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,12 +9,64 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Executable Trust Governance v1 (#1873): executable trust is now one concept + with one resolver and deny-wins precedence. Organizations can now declare an + `executables:` block in `apm-policy.yml` (`deny_all`, `deny`, `require`, + `recommend`) that is carried through policy inheritance, closing the + GRANT/MANDATE asymmetry where projects could allow executables but orgs + could not deny them. A single deny-wins precedence resolver + (`resolve_exec_decision`) is now shared by both the install gate and the + `apm audit` policy checks, so the gate and the audit can never disagree. + Precedence (first match wins): org `deny_all`/`deny` > user deny > project + deny > project allow > user allow > org `recommend` > default-deny. The lockfile records a + per-dependency `exec_status` (`deployed`, `gated_pending_approval`, + `denied`, `absent`). No cryptographic signing or `enforce`-mandate + execution is introduced in v1 (an unverified `enforce` rung fail-safe + degrades to `recommend`). (by @sergio-sisternes-epam) (#1873) +- `apm policy explain ` prints the effective executable-trust decision + for a package: whether it is allowed, the deciding policy layer, and any + layers it shadows. `apm doctor` adds a fleet-level executable-trust drift + check that flags packages allowed locally but denied by org policy. (#1873) +- `apm approve --recommended` bulk-accepts an organization's `recommend` + set, and `apm approve --list` shows the effective trust state of every + installed package with executables. (#1873) - Org-wide policy discovery now cascades through candidate repo names (`.github`, then `.apm`, then `_apm`) and speaks the Azure DevOps Items API, so Azure DevOps organizations -- which forbid repo names that begin or end with `.` -- can host an APM governance policy repo for the first time. (by @sergio-sisternes-epam; closes #1813) (#1830) +### Changed + +- Executable-trust vocabulary is unified onto one noun, `executables`. + `apm approve` / `apm deny` now default to the project `apm.yml` + `executables: {allow, deny}` block (the committed, team-wide admin + decision); pass `--user` to write personal consent to + `~/.apm/config.json` (the lowest-authority, machine-local override that + can only narrow). (#1873) +- The `required-packages-deployed` audit check now asserts package + PRESENCE in the lockfile rather than materialized `deployed_files`, so an + install SUCCEEDS when a required package is present-but-parked (its + executables gated pending approval) and prints a one-command remedy + instead of hard-failing. A separate `required-executable-untrusted` + signal hard-fails CI when a required package's executables are untrusted. + (#1873) + +### Deprecated + +- The project `allowExecutables:` block is deprecated in favor of + `executables.allow`. It remains a read alias for one minor cycle and is + migrated to `executables.allow` on the next `apm approve`/`apm deny` + write. The org `bin_deploy` deny policy is folded into + `executables.deny[bin]` as a deprecated alias. (#1873) + +### Removed + +- The standalone `~/.apm/approvals.yml` personal-consent file is removed; + its contents are migrated into `~/.apm/config.json` under + `executables: {allow, deny}` on first read (net-new control-surface + files = 0). (#1873) + ### Fixed - `apm install @` now preserves GitLab and other diff --git a/docs/src/content/docs/concepts/primitives-and-targets.md b/docs/src/content/docs/concepts/primitives-and-targets.md index 71315e7b6..805081bec 100644 --- a/docs/src/content/docs/concepts/primitives-and-targets.md +++ b/docs/src/content/docs/concepts/primitives-and-targets.md @@ -70,7 +70,7 @@ Model Context Protocol servers declared as dependencies. APM writes the per-harn ### 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 approved via `allowExecutables` in `apm.yml` and `apm approve `, because they are arbitrary executable code. Project scope deploys to `.github/extensions/`; `--global` deploys a dependency canvas to `~/.copilot/extensions/` (always requiring approval). +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 approved via the `executables` block in `apm.yml` and `apm approve `, because they are arbitrary executable code. Project scope deploys to `.github/extensions/`; `--global` deploys a dependency canvas to `~/.copilot/extensions/` (always requiring approval). - Source: `.apm/extensions//extension.mjs` - Deploys to: `.github/extensions//` (project) or `~/.copilot/extensions//` (`--global`) @@ -135,7 +135,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 the package is approved via `allowExecutables` in `apm.yml` (`apm approve `). 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 approval (first-party global install is not supported). Every other harness is `unsupported`: a canvas is a Copilot CLI construct only. +- `canvas / copilot = gated` -- requires the `canvas` experimental flag; a canvas shipped by a dependency is executable code, so it stays blocked until the package is approved via the `executables` block in `apm.yml` (`apm approve `). 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 approval (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 bd1f037d9..a721386bb 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. (**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, when the project opts in via `allowExecutables:` in `apm.yml`, the `allowExecutables` approval gate for dependency-provided canvases. See [Canvas extensions](/apm/integrations/canvas/).) +- **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 the [executable trust gate](#executable-trust-gate) 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. @@ -304,6 +304,53 @@ across targets. | **OpenCode** | `.opencode/commands/*.md` | Deployed when `.opencode/` exists. | | **Gemini CLI** | `.gemini/commands/*.toml` | Deployed when `.gemini/` exists. | +## Executable trust gate + +APM blocks executable primitives from dependency packages by default: hooks, +`bin/` executables, self-defined MCP servers (`registry: false`), and canvas +extensions. Text primitives (skills, agents, instructions) are never gated, and +local root `.apm/` content is always trusted. + +Trust is expressed through one noun, `executables`, across three layers, and the +install gate and `apm audit` resolve it through a single deny-wins, +first-match-wins ladder: + +``` +1. org deny_all / org deny -> denied (absolute ceiling) +2. user deny -> denied +3. project deny -> denied +4. project allow -> allowed +5. user allow -> allowed +6. org recommend -> allowed (user-overridable) +7. (no match) -> gated pending approval (denied but approvable) +``` + +- **Org** (`apm-policy.yml` `executables:`) is the ceiling on deny. It can + `deny_all`, `deny` packages, `require` packages be present and trusted, and + `recommend` a vetted set. See [executables](../reference/policy-schema/#executables) in the policy + schema. +- **Project** (`apm.yml` `executables.{allow,deny}`) is committed admin trust, + shared with the team. +- **User** (`~/.apm/config.json` `executables.{allow,deny}`) is the lowest + authority -- a machine-local override that can only narrow, never widen past + an org or project deny. + +Personal consent can never widen past an org deny, and the default (rung 7) is +**gated pending approval** -- a package with executables and no opinion anywhere +is parked until approved, not hard-denied. This release ships no `enforce` +mandate runtime, no signing, and no content-hash binding; an org +`executables.enforce` rung degrades to `recommend`. + +Each locked dependency records its resolved state in the `exec_status` field of +`apm.lock.yaml` (`deployed`, `gated_pending_approval`, `denied`, or `absent`). +For CI, `apm install` succeeds when a required package is present-but-parked and +prints a one-command remedy (e.g. `apm approve `); a separate audit signal, +`required-executable-untrusted`, hard-fails when a required package's +executables are untrusted. Manage trust with [`apm approve` / `apm +deny`](../reference/cli/approve/), inspect the deciding layer for one +package with `apm policy explain `, and surface fleet-wide layer +conflicts with `apm doctor`. + ## MCP server trust model APM integrates MCP (Model Context Protocol) server configurations from packages. Trust is explicit and scoped by dependency depth. diff --git a/docs/src/content/docs/integrations/canvas.md b/docs/src/content/docs/integrations/canvas.md index 356425cd5..d5baeddb0 100644 --- a/docs/src/content/docs/integrations/canvas.md +++ b/docs/src/content/docs/integrations/canvas.md @@ -71,33 +71,38 @@ is not picked up mid-session. ## Trust gate for dependency canvases A canvas shipped by a **dependency** is arbitrary executable Node.js code. When -the project opts in to the executable gate (by adding `allowExecutables:` to +the project opts in to the executable gate (by adding an `executables:` block to `apm.yml`), APM blocks dependency-provided canvases unless the package has been explicitly approved. To deploy them: ```yaml # apm.yml (committed -- opts the project in to the gate) -allowExecutables: {} +executables: {} ``` ```bash -# Run once per developer; approval is stored in ~/.apm/approvals.yml (NOT committed) +# apm approve writes committed project trust (shared with the team); +# add --user to record a personal grant in ~/.apm/config.json instead. apm approve some-org/canvas-package apm install --target copilot ``` -`apm approve` writes grants to `~/.apm/approvals.yml` -- a user-local file -that is never committed to source control. This means cloning a project with -`allowExecutables: {}` does **not** automatically grant trust to any package; -each developer must explicitly approve packages they want to deploy. For -automated CI pipelines, grants can alternatively be listed directly in -`apm.yml` (committed, shared with the team). +By default `apm approve` writes the grant to the project `apm.yml` +`executables.allow` block (committed), so the trust decision is shared with the +team. `apm approve --user` records a personal grant in `~/.apm/config.json` +instead -- a machine-local override that is never committed. Adding an empty +`executables: {}` enables the gate but grants trust to nothing; approve each +package you want to deploy. + +The legacy top-level `allowExecutables:` block is a deprecated alias for +`executables.allow`, read for one minor cycle and migrated on the next +`apm approve` / `apm deny` write. 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 **`allowExecutables` block** decides whether *dependency* canvases may +- The **`executables` block** 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 approval. @@ -125,7 +130,7 @@ Global canvas install is intentionally limited in this experimental release: 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. - **Approval is always required.** A global canvas has full-account blast radius, - so `allowExecutables` approval is mandatory even though the project-scope + so executable-trust approval 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 @@ -147,13 +152,14 @@ experimental flag, so a previously-installed canvas can always be removed. (`--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 `allowExecutables` approval) and the default `~/.copilot` location + requiring executable-trust approval) 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 governed by `allowExecutables` - in `apm.yml`; a dedicated `apm-policy.yml` field for org-wide canvas policy is - planned but not part of this experimental release. +- **No canvas-specific org policy field yet.** The org `executables:` block in + `apm-policy.yml` governs canvas trust alongside the other executable types + (`deny_all`, `deny`, `require`, `recommend`); a canvas-only policy knob is not + planned for 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/producer/repo-shapes.md b/docs/src/content/docs/producer/repo-shapes.md index 2d34603d3..0fca2d601 100644 --- a/docs/src/content/docs/producer/repo-shapes.md +++ b/docs/src/content/docs/producer/repo-shapes.md @@ -213,9 +213,10 @@ Claude Code skills target. Authoring rules: **without per-call confirmation**. Treat them as trusted code: keep them minimal, audited, and free of network side effects you would not want an agent to trigger unprompted. -- Enterprises can deny deployment per-package or globally via the - `bin_deploy` policy rule -- see the - [policy schema](../../reference/policy-schema/#bin_deploy). +- Enterprises can deny deployment per-package or globally via the org + `executables.deny` policy (the legacy `bin_deploy` rule remains a + deprecated alias) -- see the + [policy schema](../../reference/policy-schema/#executables). ## What to read next diff --git a/docs/src/content/docs/reference/cli/approve.md b/docs/src/content/docs/reference/cli/approve.md index 9dbf6e549..2a5a46ded 100644 --- a/docs/src/content/docs/reference/cli/approve.md +++ b/docs/src/content/docs/reference/cli/approve.md @@ -9,33 +9,29 @@ sidebar: ```bash apm approve [PACKAGE_REF...] [OPTIONS] -apm deny [PACKAGE_REF...] +apm deny [PACKAGE_REF...] [OPTIONS] +apm policy explain ``` ## Description -APM blocks executable primitives (hooks, bin/ executables, self-defined MCP -servers, and canvas extensions) from dependency packages by default. The -`allowExecutables` block in `apm.yml` opts the **project** in to the gate. -User-specific approvals are stored in **`~/.apm/approvals.yml`** -- a -personal file that is never committed to source control. +APM blocks executable primitives (hooks, `bin/` executables, self-defined MCP +servers, and canvas extensions) from dependency packages by default. Trust is +expressed through one noun, `executables`, across three layers: -`apm approve` adds a package to the user-local allowlist. `apm deny` removes it. +| Layer | Store | Who manages it | Committed? | Authority | +|-------|-------|----------------|------------|-----------| +| Project | `apm.yml` `executables.{allow,deny}` | Maintainer / CI setup (`apm approve`/`apm deny`) | Yes | Admin (shared) | +| User | `~/.apm/config.json` `executables.{allow,deny}` | `apm approve --user` / `apm deny --user` | No | Lowest; can only narrow | +| Org | `apm-policy.yml` `executables:` | Org admin | Yes (policy repo) | Ceiling on deny | -### How the gate works +`apm approve` adds a grant; `apm deny` adds a block. By default both write the +**project** `apm.yml` (committed, so the whole team inherits the decision). +`--user` writes your personal `~/.apm/config.json` instead -- a machine-local +override that can only narrow trust, never widen past an org or project deny. -When `apm install` encounters a dependency that ships executable primitives: - -1. If `allowExecutables` is **absent** from `apm.yml`, everything is - approved (backward-compatible, no gate). -2. If `allowExecutables` is **present** (even empty `{}`), only packages - approved in `~/.apm/approvals.yml` (or listed directly in `apm.yml` - for CI pipelines) may deploy executables. -3. In interactive mode, `apm install` prompts for each unapproved - package. In CI (non-interactive), unapproved executables cause a - hard error. - -Local project content (the root `.apm/` directory) is always trusted. +Text primitives (skills, agents, instructions) are never gated. Local project +content (the root `.apm/` directory) is always trusted. ### What is gated @@ -47,16 +43,37 @@ Local project content (the root `.apm/` directory) is always trusted. | Canvas extensions (`.apm/extensions/`) | Yes | Deploys executable Node.js to IDE extensions | | Text primitives (skills, agents, instructions) | No | No code execution risk | -### Where approvals are stored +### Precedence (deny-wins, first match wins) + +The install gate and `apm audit` resolve trust through one shared ladder. The +first matching rung decides: + +``` +1. org deny_all / org deny -> denied (absolute ceiling) +2. user deny -> denied (narrowing) +3. project deny -> denied (committed narrowing) +4. project allow -> allowed +5. user allow -> allowed +6. org recommend -> allowed (user-overridable) +7. (no match) -> gated pending approval (denied but approvable) +``` + +Deny always wins. The org layer is the ceiling on deny -- personal consent +cannot widen past an org or project deny. The default (rung 7) is **gated +pending approval**, not a hard deny: a package with executables and no opinion +anywhere is parked until you approve it, and `apm install` still succeeds (see +[`apm install`](../install/)). -| Store | Path | Who manages it | Committed? | -|-------|------|----------------|------------| -| User-local approvals | `~/.apm/approvals.yml` | `apm approve` / `apm deny` | No | -| Project gate + CI grants | `apm.yml` (`allowExecutables`) | Project maintainer / CI setup | Yes | +There is no `enforce` mandate runtime, no cryptographic signing, and no +content-hash binding in this release. An org `executables.enforce` rung +degrades to `recommend` (allowed but still overridable by a deny). -Approvals from both stores are merged at install time. The project `apm.yml` -signals that the gate is enabled and may include pre-approved packages for CI; -developer approvals live in the user file and are personal. +### The gate opt-in + +The gate is enabled when any layer opts in: the project declares an +`executables:` block (even empty `{}`), or the org policy carries a non-empty +`executables:` block. Without any opt-in, executables deploy unconditionally +(backward-compatible). ## Options @@ -64,75 +81,136 @@ developer approvals live in the user file and are personal. | Flag | Description | |------|-------------| -| `PACKAGE_REF` | One or more packages to approve (e.g. `ci-hooks@acme`). | +| `PACKAGE_REF` | One or more packages to approve (e.g. `owner/repo`). | | `--pending` | List all packages with unapproved executables. | | `--all` | Approve all currently blocked packages. | +| `--recommended` | Bulk-accept the org `executables.recommend` set. | +| `--list` | Show the fleet-level effective trust decision and deciding layer per installed package. | +| `--user` | Write the grant to `~/.apm/config.json` instead of `apm.yml`. | ### `apm deny` | Flag | Description | |------|-------------| -| `PACKAGE_REF` | One or more packages to deny (removes from user-local allowlist). | +| `PACKAGE_REF` | One or more packages to deny. Denying a not-yet-installed package is allowed (a pre-emptive block). | +| `--user` | Write the block to `~/.apm/config.json` instead of `apm.yml`. | + +### `apm policy explain` + +`apm policy explain ` prints the effective executable-trust +decision for a package: allowed or blocked per executable type, the deciding +policy layer, and any shadowed (overridden) lower-authority layers. It is a +subcommand of the [`apm policy`](../policy/) group -- the per-package companion +to `apm policy status` (the policy-chain view). + +```bash +apm policy explain owner/repo +``` + +For a fleet-level view, `apm doctor` runs an executable-trust drift check that +flags any package allowed locally but denied by org policy and points to `apm +policy explain` for the per-package detail. -## User approvals file format +## Store format -`apm approve` writes to `~/.apm/approvals.yml`. The file stores the approvals -mapping directly, keyed by `name#version` with per-type boolean flags: +The project `executables.allow` / `executables.deny` maps are keyed by +`owner/repo#version` (or version-blind `owner/repo`) with per-type boolean +flags: ```yaml -# ~/.apm/approvals.yml (auto-generated, do not commit) -"ci-hooks@acme#1.2.0": - hooks: true - bin: true -"dev-tools@org#0.5.0": - hooks: true +# apm.yml (committed) +executables: + allow: + "owner/repo#1.2.0": + hooks: true + bin: true + deny: + "evil/pkg": + hooks: true + mcp: true + bin: true + canvas: true ``` -Version pinning means approval must be renewed when a package updates. +The legacy top-level `allowExecutables:` block is **deprecated**. It is still +read as an alias for `executables.allow` for one minor cycle and is migrated to +`executables.allow` on the next `apm approve` / `apm deny` write. Prefer +`executables.allow`. + +The personal store uses the same shape under `executables` in +`~/.apm/config.json`. The standalone `~/.apm/approvals.yml` file has been +**removed**; its contents are migrated into `~/.apm/config.json` automatically +on first read. + +Grant keys are package-scoped in v1: a bare `owner/repo` key and a +`owner/repo#1.2.0` key both match the package name regardless of the installed +version. Use the versioned form for audit readability, not as a per-release +trust boundary. ## Examples -Approve a specific package: +Approve a specific package (writes committed project trust): ```bash -apm approve ci-hooks@acme +apm approve owner/repo ``` -Show all blocked packages: +Approve for this machine only: + +```bash +apm approve --user owner/repo +``` + +List packages awaiting approval: ```bash apm approve --pending ``` -Approve everything (migration helper): +After review, approve everything still pending: ```bash apm approve --all ``` -Revoke approval: +Accept the org-recommended set: + +```bash +apm approve --recommended +``` + +Inspect effective trust state across installed packages: + +```bash +apm approve --list +``` + +Block a package (deny always wins): ```bash -apm deny ci-hooks@acme +apm deny evil/pkg ``` ## Non-interactive / CI usage -In CI environments (`CI=true`, `APM_NON_INTERACTIVE=1`, or when stdin -is not a TTY), `apm install` fails with exit code 1 if any dependency -has unapproved executables. Pre-approve packages by listing them -directly in `apm.yml` (this is the only way to share approvals via -source control): +In CI environments (`CI=true`, `APM_NON_INTERACTIVE=1`, or when stdin is not a +TTY), `apm install` parks unapproved executables and prints the approval +remedy instead of prompting. Pre-approve packages by committing them to the +project `executables.allow` block (the way to share trust via source control). +Required-but-untrusted executables are enforced by `apm audit` through the +`required-executable-untrusted` signal: ```yaml # apm.yml -allowExecutables: - "ci-hooks@acme#1.2.0": - hooks: true - bin: true +executables: + allow: + "ci-hooks/acme#1.2.0": + hooks: true + bin: true ``` ## See also - [`apm install`](../install/) -- the install command that enforces the gate - [`apm audit`](../audit/) -- audit installed packages +- [apm-policy.yml schema](../policy-schema/) -- the org `executables:` ceiling diff --git a/docs/src/content/docs/reference/experimental.md b/docs/src/content/docs/reference/experimental.md index 0e86d5c44..c65c55a0c 100644 --- a/docs/src/content/docs/reference/experimental.md +++ b/docs/src/content/docs/reference/experimental.md @@ -175,7 +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 require `allowExecutables` approval (`apm approve `). See [Canvas extensions](../integrations/canvas/). | +| `canvas` | Ship Copilot CLI canvas extensions (`.apm/extensions//extension.mjs`) through APM packages. Dependency-provided canvases require executable-trust approval (`apm approve `). 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/docs/src/content/docs/reference/policy-schema.md b/docs/src/content/docs/reference/policy-schema.md index 210d28cf5..da9af9cfa 100644 --- a/docs/src/content/docs/reference/policy-schema.md +++ b/docs/src/content/docs/reference/policy-schema.md @@ -64,7 +64,8 @@ The `` accepts: | `unmanaged_files` | object | see section | no | Rules over files in target directories not tracked by the lockfile. | | `security` | object | see section | no | Rules over APM's security checks (install-time content audit + external scanners; requires `external-scanners` flag). | | `registry_source` | object | see section | no | Mandate registry usage and block non-registry sources (requires `registries` flag). | -| `bin_deploy` | object | see section | no | Control whether `marketplace_plugin` bin/ executables are deployed to `~/.claude/skills//bin/`. | +| `executables` | object | see section | no | Org ceiling for executable-primitive trust (hooks, bin, self-defined MCP, canvas). See [executables](#executables). | +| `bin_deploy` | object | see section | no | DEPRECATED alias folded into `executables.deny` (bin-scoped). See [bin_deploy](#bin_deploy). | Unknown top-level keys produce a warning, never an error -- so newer policy files load on older clients. @@ -291,6 +292,8 @@ inherited list (see the tri-state table below). | `security.audit.scanners` | Union of scanner names; per scanner `allow_args` is AND-merged (any ancestor `false` wins -- tightening). `null` is transparent. | | `security.audit.fail_on_drift` | Logical OR -- once a parent enables it, a child cannot relax. | | `security.integrity.require_hashes` | Logical OR -- once a parent enables it, a child cannot relax. | +| `executables.deny_all` | Logical OR -- any ancestor kill-switch (`true`) sticks. | +| `executables.deny` / `require` / `recommend` / `enforce` | Union, deduplicated. A child adds packages but never drops a parent's. | | `compilation.*.enforce` | First non-null wins (parent precedence). | | `compilation.source_attribution` | Logical OR. | @@ -400,9 +403,51 @@ registry_source: allow_non_registry: false ``` +## executables + +The org ceiling for executable-primitive trust. Unifies the executable-trust +vocabulary onto one noun, `executables`, governing all four gated types: hooks, +`bin/` executables, self-defined MCP servers (`registry: false`), and canvas +extensions. The org layer is the ceiling on **deny** -- it can deny and require +fleet-wide, and recommend a vetted set, but personal or project consent can +never widen past an org deny. + +| Field | Type | Default | Description | +|---|---|---|---| +| `deny_all` | `bool` | `false` | When `true`, blocks every executable type for every package org-wide. | +| `deny` | `list` | `[]` | Canonical package strings (`owner/name`, glob allowed, e.g. `evil/*`) whose executables must not deploy. Deny is the ceiling and always wins. | +| `require` | `list` | `[]` | Packages whose executables MUST be present and trusted. A required package whose executables are untrusted hard-fails the `required-executable-untrusted` audit check in CI. | +| `recommend` | `list` | `[]` | Org-vetted set: default-allowed unless locally denied. Bulk-accepted with `apm approve --recommended`. | + +```yaml +# apm-policy.yml +executables: + deny_all: false + deny: ["evil/*"] + require: ["acme/ci"] + recommend: ["acme/fmt"] +``` + +The install gate and `apm audit` resolve trust through one shared deny-wins, +first-match-wins ladder (org deny > user deny > project deny > project allow > +user allow > org recommend > default-deny). Each locked dependency records the +resolved state in the `exec_status` field of `apm.lock.yaml` (one of +`deployed`, `gated_pending_approval`, `denied`, `absent`). For the consumer-side +commands that write project and personal trust, see [apm approve / apm +deny](./cli/approve/). + +There is no `enforce` mandate runtime, no cryptographic signing, and no +content-hash binding in this release: an `executables.enforce` rung is accepted +in policy but fail-safe degrades to `recommend` (allowed, still overridable by a +deny). + ## bin_deploy -Controls whether `apm install -g` deploys `bin/` executables from `marketplace_plugin` packages into `~/.claude/skills//bin/`, alongside the package's `.claude-plugin/plugin.json`. Here `` is the package's install directory name (typically the repository name). +> **Deprecated:** `bin_deploy` is the bin-scoped predecessor of `executables`. +> It is folded into `executables.deny` (bin type only) and honored as an alias +> for one minor cycle. Prefer `executables.deny` for new policies. + + This realizes Claude Code's "skills-directory plugin" contract: a folder under a skills directory that contains `.claude-plugin/plugin.json` loads as `@skills-dir`, and its root `bin/` is added to the Bash tool's `PATH`. The package's `.claude-plugin/plugin.json` is required for Claude to load the folder as a plugin; APM copies it alongside `bin/` when the package ships one. The contract is Claude-specific, so deployment only targets Claude. Restart Claude Code (or run `/reload-plugins`) after install for new executables to be picked up. diff --git a/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md index 09062cd0b..df2f0af5b 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/commands.md +++ b/packages/apm-guide/.apm/skills/apm-usage/commands.md @@ -231,4 +231,10 @@ Experimental flags MUST NOT gate security-critical behaviour (content scanning, `apm config set mcp-registry-url https://mcp.internal.example.com` persists a private MCP registry URL so users do not need to export `MCP_REGISTRY_URL` every session. Accepts `http://` or `https://` URLs; all other schemes are rejected. Resolution order: `--registry ` flag on `apm mcp install` / `apm install --mcp` > `MCP_REGISTRY_URL` env var > `mcp-registry-url` in `~/.apm/config.json` > built-in public default. When the config layer is active, `apm mcp search` prints a `Registry (config): ` diagnostic. `apm config unset mcp-registry-url` removes the persisted URL. -`apm approve [PACKAGE_REF...]` grants a package permission to deploy executable primitives (hooks, bin/, self-defined MCP servers, canvas extensions). Approvals are written to `~/.apm/approvals.yml` -- a user-local file that is never committed to source control. The project `apm.yml` must have an `allowExecutables:` key (even empty `{}`) to enable the gate; without it, all executables are allowed unconditionally. `apm approve --pending` lists all packages with unapproved executables. `apm approve --all` approves every currently blocked package. `apm deny [PACKAGE_REF...]` removes a package from the user-local allowlist. Approvals are version-pinned (`name#version`); updating a package requires re-approval. In non-interactive environments (CI), pre-approve packages by listing them directly in `apm.yml` (the only way to share approvals via source control). Unapproved executables cause `apm install` to exit 1 in CI. +`apm approve [PACKAGE_REF...]` grants a package permission to deploy executable primitives (hooks, `bin/`, self-defined MCP servers, canvas extensions). By default it writes the project `apm.yml` `executables.allow` block -- committed to source control, so the whole team inherits the trust decision. `apm approve --user` records a personal grant in `~/.apm/config.json` instead (machine-local, never committed, lowest authority -- it can only narrow trust). + +The project `apm.yml` must carry an `executables:` block (even empty `{}`) to enable the gate; without it, all executables are allowed unconditionally. Flags: `--pending` lists packages with unapproved executables; `--all` approves every currently blocked package; `--recommended` bulk-accepts the org `executables.recommend` set; `--list` shows the fleet-level effective trust decision and deciding layer per installed package. + +`apm deny [PACKAGE_REF...]` writes a block to the project `executables.deny` (or `~/.apm/config.json` with `--user`); deny always wins, and denying a not-yet-installed package is allowed (a pre-emptive block). `apm policy explain ` (a subcommand of the `apm policy` group, sibling to `apm policy status`) prints the effective decision for a package: allowed or blocked per type, the deciding policy layer, and any shadowed (overridden) layers. `apm doctor` adds a fleet-level executable-trust drift check that flags packages allowed locally but denied by org policy. + +The legacy top-level `allowExecutables:` block is a deprecated alias for `executables.allow`, read for one minor cycle and migrated on the next approve/deny write; the standalone `~/.apm/approvals.yml` is removed and migrated into `~/.apm/config.json` on first read. Grant keys are package-scoped in v1: `owner/repo` and `owner/repo#version` both match the package regardless of installed version. In CI, pre-approve packages by committing them to `executables.allow`; untrusted required executables fail `apm audit` with `required-executable-untrusted`. diff --git a/packages/apm-guide/.apm/skills/apm-usage/governance.md b/packages/apm-guide/.apm/skills/apm-usage/governance.md index 020b911d5..c40a3b83a 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/governance.md +++ b/packages/apm-guide/.apm/skills/apm-usage/governance.md @@ -62,9 +62,15 @@ registry_source: # experimental: requires `apm experiment require: [] # registry names that MUST be reachable in the merged registry map allow_non_registry: true # when false, blocks any dep not routed through a configured registry -bin_deploy: # marketplace_plugin bin/ executable deployment (Claude, global installs) +bin_deploy: # DEPRECATED alias, folded into executables.deny (bin type) deny_all: false # when true, suppress bin/ deploy for every plugin deny: [] # canonical strings (owner/name) whose bin/ must not deploy + +executables: # org ceiling for executable-primitive trust (issue #1873) + deny_all: false # when true, block EVERY executable type for all packages + deny: [] # packages whose executables must not deploy (deny is the ceiling) + require: [] # packages whose executables MUST be present and trusted + recommend: [] # org-vetted set; default-allow unless locally denied ``` ## Registry source governance (experimental) @@ -143,7 +149,68 @@ Notes: it (this avoids turning a checked-in policy file into a content-exfiltration channel). -## Plugin bin/ deployment governance +## Executable trust governance + +Issue #1873 unifies executable-primitive trust (hooks, `bin/` executables, +self-defined MCP servers, canvas extensions) onto one noun, `executables`, +across three layers. The org policy is the **ceiling on deny**: it can deny and +require fleet-wide and recommend a vetted set, but personal or project consent +can never widen past an org deny. + +```yaml +# .github/apm-policy.yml +executables: + deny_all: false # kill-switch: deny every executable type org-wide + deny: ["evil/*"] # packages whose executables must not deploy (ceiling) + require: ["acme/ci"] # executables MUST be present and trusted + recommend: ["acme/fmt"] # org-vetted; default-allow unless locally denied +``` + +| Field | Default | Behavior | +|-------|---------|----------| +| `deny_all` | `false` | When `true`, blocks every executable type for every package. | +| `deny` | `[]` | Canonical package strings (`owner/name`, glob allowed) whose executables must not deploy. Deny always wins. Union-merged across inheritance. | +| `require` | `[]` | Packages whose executables MUST be present and trusted. Union-merged. | +| `recommend` | `[]` | Org-vetted set; default-allowed unless locally denied. Bulk-accepted with `apm approve --recommended`. Union-merged. | + +The install gate and `apm audit` resolve trust through one shared deny-wins, +first-match-wins ladder: + +``` +1. org deny_all / org deny -> denied (absolute ceiling) +2. user deny -> denied +3. project deny -> denied +4. project allow -> allowed +5. user allow -> allowed +6. org recommend -> allowed (user-overridable) +7. (no match) -> gated pending approval (denied but approvable) +``` + +The project layer is `apm.yml` `executables.{allow,deny}` (committed, via +`apm approve` / `apm deny`); the user layer is `~/.apm/config.json` +`executables.{allow,deny}` (machine-local, via `--user`, lowest authority). +Each locked dependency records its resolved state in the `exec_status` field of +`apm.lock.yaml` (`deployed`, `gated_pending_approval`, `denied`, `absent`). + +In CI, `apm install` SUCCEEDS when a required package is present-but-parked +(executables gated pending approval) and prints a one-command remedy +(`apm approve `); the `required-packages-deployed` audit check asserts +package PRESENCE, not materialized files. A separate audit signal, +`required-executable-untrusted`, hard-fails CI when a required package's +executables are untrusted (denied or gated). + +There is no `enforce` mandate runtime, no cryptographic signing, and no +content-hash binding in this release: an org `executables.enforce` rung is +accepted but fail-safe degrades to `recommend` (allowed, still overridable by a +deny). Inspect the deciding layer for one package with `apm policy explain +`, and surface fleet-wide layer conflicts (packages allowed locally but +denied by org policy) with `apm doctor`. + +## Plugin bin/ deployment governance (deprecated alias) + +> `bin_deploy` is the bin-scoped predecessor of `executables`. It is folded into +> `executables.deny` (bin type only) and honored as an alias for one minor +> cycle. Prefer `executables.deny` for new policies. When a `marketplace_plugin` package ships a `bin/` directory, a global install (`apm install -g`) deploys those executables into @@ -176,22 +243,21 @@ 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 when the project opts in to the executable gate: -the project must add `allowExecutables: {}` to `apm.yml` and each developer -must run `apm approve ` to deploy it. A first-party canvas in the root -package being installed deploys once the flag is on; dependency canvases always -require explicit approval. - -Approvals recorded by `apm approve` are stored in **`~/.apm/approvals.yml`** -(user-local, never committed to source control). Adding `allowExecutables: {}` -to `apm.yml` enables the gate for the project, but does not grant trust to any -package; every developer cloning the project must approve packages themselves. -CI pipelines may commit specific grants directly in `apm.yml` to bypass -interactive approval in automated environments. +the project must add an `executables:` block to `apm.yml` and run +`apm approve ` to deploy it. A first-party canvas in the root package being +installed deploys once the flag is on; dependency canvases always require +explicit approval. + +By default `apm approve` records the grant in the project `apm.yml` +`executables.allow` block (committed, shared with the team); `apm approve --user` +records a personal grant in `~/.apm/config.json` (machine-local, never +committed). Adding an empty `executables: {}` enables the gate but grants trust +to nothing. 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 `allowExecutables` approval (full-account blast +global install always requires executable-trust approval (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 @@ -199,10 +265,11 @@ global canvas. The trust gate is enforced on every install path -- normal install and offline bundle install (`apm install `) -- so a vendored bundle cannot smuggle -an executable canvas past trust. Canvas trust is now unified with the -`allowExecutables` default-deny gate (hooks, bin, mcp, canvas); approve once and -all four executable types are governed consistently. An enterprise policy field -for canvas trust is a deferred follow-up and is not part of this experimental +an executable canvas past trust. Canvas trust is unified with the `executables` +default-deny gate (hooks, bin, mcp, canvas); approve once and all four +executable types are governed consistently. The org `executables:` policy block +governs canvas trust alongside the other types (`deny_all`, `deny`, `require`, +`recommend`); a canvas-only policy knob is not part of this experimental release. ## Local content governance 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 8750c89ca..3dbcf0d3e 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md +++ b/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md @@ -398,8 +398,9 @@ target is present. Authoring rules: - Deployed executables land on Claude Code's PATH and are invoked **without per-call confirmation** -- treat them as trusted code and keep them minimal. -- 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). +- Governance: the org `executables.deny` policy can deny deployment per + package (the legacy `bin_deploy` rule remains a deprecated alias). + See the [policy schema](../../../../../docs/src/content/docs/reference/policy-schema.md#executables). ## Canvas extensions (experimental, Copilot-only) @@ -412,7 +413,8 @@ 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 adds the package to `allowExecutables` and runs +and are blocked unless the consumer adds the package to the `executables.allow` +block in `apm.yml` (`allowExecutables` is a deprecated alias) and runs `apm approve `; a first-party canvas in the root package deploys once the flag is on. With `--global`, a dependency canvas deploys to `~/.copilot/extensions//` diff --git a/src/apm_cli/commands/approve.py b/src/apm_cli/commands/approve.py index 852f1832c..936dc67f2 100644 --- a/src/apm_cli/commands/approve.py +++ b/src/apm_cli/commands/approve.py @@ -1,10 +1,19 @@ -"""``apm approve`` and ``apm deny`` -- manage executable primitive approvals. - -These commands mirror npm v12's ``npm approve-scripts`` / ``npm deny-scripts``. -They read and write the user-local ``~/.apm/approvals.yml`` file so that -approval decisions are never committed to source control. The project -``apm.yml`` only signals whether the gate is enabled (``allowExecutables: {}`` -key present) -- individual package approvals live in the user file. +"""``apm approve`` / ``apm deny`` -- manage executable-primitive trust. + +Issue #1873 unifies the vocabulary onto one noun, ``executables``, and gives +the commands two clearly-scoped destinations: + +* DEFAULT (admin UX): the project ``apm.yml`` ``executables: {allow, deny}`` + block -- committed to source control so the whole team inherits the trust + decision. This is where a maintainer pins the dependencies their project + trusts. +* ``--user`` (personal UX): ``~/.apm/config.json`` ``executables: {allow, + deny}`` -- never committed, lowest authority, a personal override on the + current machine only. + +The deny-wins precedence is resolved by +:func:`apm_cli.security.executables.resolve_exec_decision`; ``--list`` and +``apm policy explain`` surface the effective decision and the deciding layer. """ from __future__ import annotations @@ -26,106 +35,295 @@ def _find_manifest() -> Path: return manifest -def _load_allow_executables() -> dict[str, dict[str, bool]] | None: - """Load the user-local approvals from ``~/.apm/approvals.yml``. +def _load_store( + manifest: Path, user_scope: bool +) -> tuple[dict[str, dict[str, bool]], dict[str, dict[str, bool]]]: + """Load the (allow, deny) grant maps from the selected store.""" + from ..security.executables import load_project_executables, load_user_executables - Returns ``None`` when no approvals have been recorded yet (gate behaviour - is determined by the project manifest, not this file). - """ - from ..security.executables import get_user_approvals_path, load_user_approvals + if user_scope: + return load_user_executables() + allow, deny, _alias = load_project_executables(manifest) + return allow, deny + + +def _save_store( + manifest: Path, + user_scope: bool, + allow: dict[str, dict[str, bool]], + deny: dict[str, dict[str, bool]], +) -> None: + """Persist the (allow, deny) grant maps to the selected store.""" + from ..security.executables import save_user_executables, write_project_executables + + if user_scope: + save_user_executables(allow, deny) + else: + write_project_executables(manifest, allow, deny) + + +def _store_label(user_scope: bool) -> str: + return "~/.apm/config.json" if user_scope else "apm.yml executables block" + + +def _load_org_policy(project_root: Path): + """Best-effort load of the merged org policy. Returns a default on failure.""" + from ..policy.schema import ApmPolicy - if not get_user_approvals_path().is_file(): - return None - return load_user_approvals() + try: + from ..policy.discovery import discover_policy + + result = discover_policy(project_root) + if getattr(result, "policy", None) is not None: + return result.policy + except Exception: + pass + return ApmPolicy() + + +def load_org_policy(project_root: Path): + """Public wrapper for best-effort merged org policy loading.""" + return _load_org_policy(project_root) @click.command("approve") @click.argument("packages", nargs=-1) +@click.option("--pending", is_flag=True, help="List packages with unapproved executables.") +@click.option("--all", "approve_all", is_flag=True, help="Approve every package with executables.") +@click.option( + "--recommended", + is_flag=True, + help="Approve the org-recommended executable set (executables.recommend).", +) @click.option( - "--pending", + "--list", + "list_decisions", is_flag=True, - help="List all packages with unapproved executables.", + help="List effective trust decisions; fleet view for apm policy explain.", ) @click.option( - "--all", - "approve_all", + "--user", + "user_scope", is_flag=True, - help="Approve all packages with executables.", + help="Persist to your personal ~/.apm/config.json (lowest authority) " + "instead of the shared project apm.yml.", ) -def approve_cmd(packages: tuple[str, ...], pending: bool, approve_all: bool) -> None: - """Approve executable primitives for installed packages. +def approve_cmd( + packages: tuple[str, ...], + pending: bool, + approve_all: bool, + recommended: bool, + list_decisions: bool, + user_scope: bool, +) -> None: + """Approve executable primitives (hooks, MCP, bin, canvas) for packages. - Adds entries to ``~/.apm/approvals.yml`` (user-local, never committed to - source control) so that hooks, MCP servers, canvas extensions, and bin/ - executables from the specified packages are deployed during ``apm install``. + By default writes to the project ``apm.yml`` ``executables.allow`` block + (committed). Use ``--user`` to record a personal grant in + ``~/.apm/config.json`` instead. Examples: apm approve owner/repo - apm approve --pending + apm approve --recommended + + apm approve --list - apm approve --all + apm approve --user owner/repo """ manifest = _find_manifest() - allow_exec = _load_allow_executables() + + if list_decisions: + _list_decisions(manifest) + return + + allow, deny = _load_store(manifest, user_scope) if pending: - _show_pending(manifest, allow_exec or {}) + _show_pending(manifest, allow) return - if allow_exec is None: - allow_exec = {} + if recommended: + _approve_recommended(manifest, user_scope, allow, deny) + return if approve_all: - _approve_all_pending(manifest, allow_exec) + _approve_all_pending(manifest, user_scope, allow, deny) return if not packages: - _rich_error("Specify at least one package, or use --pending / --all.") + _rich_error("Specify at least one package, or use --pending / --all / --recommended.") sys.exit(1) - _approve_packages(manifest, allow_exec, packages) + _approve_packages(manifest, user_scope, allow, deny, packages) @click.command("deny") @click.argument("packages", nargs=-1, required=True) -def deny_cmd(packages: tuple[str, ...]) -> None: - """Revoke executable approval for packages. +@click.option( + "--user", + "user_scope", + is_flag=True, + help="Record the deny in your personal ~/.apm/config.json instead of apm.yml.", +) +def deny_cmd(packages: tuple[str, ...], user_scope: bool) -> None: + """Deny executable primitives for packages (a narrowing override). - Removes entries from ``~/.apm/approvals.yml`` (user-local approvals - store). + Writes a deny entry to the project ``executables.deny`` (default) or your + personal ``~/.apm/config.json`` (``--user``). Deny always wins. Example: apm deny owner/repo """ - allow_exec = _load_allow_executables() or {} + manifest = _find_manifest() + allow, deny = _load_store(manifest, user_scope) - from ..security.executables import save_user_approvals + declarations = _scan_installed_packages(manifest) + decl_key_map = {d.package_key: d for d in declarations} + decl_name_map = {d.package_name: d for d in declarations} - removed = 0 + changed = 0 for pkg in packages: - matched_key = _find_matching_key(allow_exec, pkg) - if matched_key: - del allow_exec[matched_key] - _rich_success(f"Revoked approval for {matched_key}") - removed += 1 - else: - _rich_warning(f"{pkg}: not found in allowExecutables") - - if removed > 0: - save_user_approvals(allow_exec) - _rich_info(f"Updated ~/.apm/approvals.yml ({removed} removed).", symbol="info") - - -def _find_matching_key(allow_exec: dict[str, dict[str, bool]], pkg: str) -> str | None: - """Find a key in allow_exec that matches *pkg* (exact or prefix).""" - # Exact match - if pkg in allow_exec: + decl = decl_key_map.get(pkg) or decl_name_map.get(pkg) + if decl is None: + for d in declarations: + if d.package_key.startswith(pkg + "#") or d.package_name.startswith(pkg): + decl = d + break + if decl is None: + # Allow denying a package that is not (or no longer) installed. + deny[pkg] = {t: True for t in ("hooks", "mcp", "bin", "canvas")} + allow.pop(_find_matching_key(allow, pkg) or pkg, None) + _rich_info(f"Denied {pkg} (all executable types)", symbol="info") + changed += 1 + continue + deny[decl.package_key] = {t: True for t in decl.exec_types} + allow.pop(_find_matching_key(allow, decl.package_key) or decl.package_key, None) + _rich_info(f"Denied {decl.package_key}: {decl.summary_line()}", symbol="info") + changed += 1 + + if changed > 0: + _save_store(manifest, user_scope, allow, deny) + _rich_info(f"Updated {_store_label(user_scope)} ({changed} denied).", symbol="info") + + +def explain_decision(package: str) -> None: + """Explain the effective executable-trust decision for a package. + + Shows, per executable type the package declares, whether it is allowed, + which precedence layer decided, and which lower-authority layers were + shadowed by that decision. + + Backs the ``apm policy explain `` subcommand. + + Example: + + apm policy explain owner/repo + """ + manifest = _find_manifest() + from ..security.executables import build_exec_trust_context, resolve_exec_decision + from ..utils.yaml_io import load_yaml + + data = load_yaml(manifest) + project_data = data if isinstance(data, dict) else {} + policy = _load_org_policy(manifest.parent) + ctx = build_exec_trust_context(policy=policy, project_data=project_data) + + declarations = _scan_installed_packages(manifest) + decl = None + for d in declarations: + if package in (d.package_key, d.package_name): + decl = d + break + if d.package_key.startswith(package + "#") or d.package_name.startswith(package): + decl = d + break + + if decl is None: + _rich_warning(f"{package}: not found among installed packages with executables.") + if not ctx.gate_enabled: + _rich_info("The executable-trust gate is disabled for this project.", symbol="info") + return + + if not ctx.gate_enabled: + _rich_info(f"{decl.package_key}: gate disabled -- all executables deploy.", symbol="info") + return + + _rich_echo(f"{decl.package_key}: {decl.summary_line()}") + decisions = [ + resolve_exec_decision(ctx, decl.package_key, exec_type) for exec_type in decl.exec_types + ] + has_block = any(not decision.allowed for decision in decisions) + verdict = "blocked" if has_block else "allowed" + _rich_echo(f" verdict: {verdict}") + for exec_type, decision in zip(decl.exec_types, decisions, strict=True): + state = "[+] allowed" if decision.allowed else "[x] blocked" + _rich_echo(f" {exec_type:<7} {state} (layer: {decision.deciding_layer})") + if decision.shadowed_layers: + _rich_echo(f" shadowed: {', '.join(decision.shadowed_layers)}") + if has_block: + _rich_info( + f"To trust it: `apm approve {decl.package_name}` " + f"(or `apm approve --user {decl.package_name}` for this machine only).", + symbol="info", + ) + + +def _list_decisions(manifest: Path) -> None: + """Print the effective trust decision + deciding layer per installed package.""" + from ..security.executables import build_exec_trust_context, resolve_exec_decision + from ..utils.yaml_io import load_yaml + + data = load_yaml(manifest) + project_data = data if isinstance(data, dict) else {} + policy = _load_org_policy(manifest.parent) + ctx = build_exec_trust_context(policy=policy, project_data=project_data) + + declarations = [d for d in _scan_installed_packages(manifest) if d.has_executables] + if not declarations: + _rich_success("No installed packages declare executable primitives.") + return + + if not ctx.gate_enabled: + _rich_info( + "Executable-trust gate disabled -- all executables deploy. " + "Add an `executables:` block to apm.yml to enable it.", + symbol="info", + ) + + rows: list[tuple[object, list[tuple[str, object]]]] = [] + allowed_count = blocked_count = 0 + for decl in declarations: + row: list[tuple[str, object]] = [] + for exec_type in decl.exec_types: + decision = resolve_exec_decision(ctx, decl.package_key, exec_type) + row.append((exec_type, decision)) + if decision.allowed: + allowed_count += 1 + else: + blocked_count += 1 + rows.append((decl, row)) + + _rich_info( + f"{len(declarations)} package(s) with executables " + f"({allowed_count} allowed type(s), {blocked_count} blocked type(s)).", + symbol="info", + ) + for decl, row in rows: + states = [] + for exec_type, decision in row: + mark = "+" if decision.allowed else "x" + states.append(f"{exec_type}[{mark}:{decision.deciding_layer}]") + _rich_echo(f" {decl.package_key}: {' '.join(states)}") + + +def _find_matching_key(grant_map: dict[str, dict[str, bool]], pkg: str) -> str | None: + """Find a key in *grant_map* that matches *pkg* (exact or prefix).""" + if pkg in grant_map: return pkg - # Prefix match: "owner/repo" matches "owner/repo#v1.0" - for key in allow_exec: + for key in grant_map: if key.startswith(pkg + "#"): return key return None @@ -152,15 +350,48 @@ def _show_pending(manifest: Path, allow_exec: dict[str, dict[str, bool]]) -> Non ) -def _approve_all_pending(manifest: Path, allow_exec: dict[str, dict[str, bool]]) -> None: - """Approve all installed packages with unapproved executables.""" - from ..security.executables import save_user_approvals +def _approve_recommended( + manifest: Path, + user_scope: bool, + allow: dict[str, dict[str, bool]], + deny: dict[str, dict[str, bool]], +) -> None: + """Bulk-approve the org ``executables.recommend`` set.""" + policy = _load_org_policy(manifest.parent) + recommend = set(getattr(getattr(policy, "executables", None), "recommend", ()) or ()) + if not recommend: + _rich_info("No org-recommended executables to approve.", symbol="info") + return + + declarations = {d.package_name: d for d in _scan_installed_packages(manifest)} + count = 0 + for name in sorted(recommend): + decl = declarations.get(name) + if decl is None or not decl.has_executables: + continue + allow[decl.package_key] = {t: True for t in decl.exec_types} + _rich_success(f"Approved {decl.package_key}: {decl.summary_line()}") + count += 1 + + if count == 0: + _rich_info("No installed packages match the org-recommended set.", symbol="info") + return + _save_store(manifest, user_scope, allow, deny) + _rich_info(f"Updated {_store_label(user_scope)} ({count} approved).", symbol="info") + +def _approve_all_pending( + manifest: Path, + user_scope: bool, + allow: dict[str, dict[str, bool]], + deny: dict[str, dict[str, bool]], +) -> None: + """Approve all installed packages with unapproved executables.""" declarations = _scan_installed_packages(manifest) count = 0 for decl in declarations: - if decl.has_executables and not _is_approved(allow_exec, decl): - allow_exec[decl.package_key] = {t: True for t in decl.exec_types} + if decl.has_executables and not _is_approved(allow, decl): + allow[decl.package_key] = {t: True for t in decl.exec_types} _rich_success(f"Approved {decl.package_key}: {decl.summary_line()}") count += 1 @@ -168,18 +399,18 @@ def _approve_all_pending(manifest: Path, allow_exec: dict[str, dict[str, bool]]) _rich_success("All packages with executables are already approved.") return - save_user_approvals(allow_exec) - _rich_info(f"Updated ~/.apm/approvals.yml ({count} approved).", symbol="info") + _save_store(manifest, user_scope, allow, deny) + _rich_info(f"Updated {_store_label(user_scope)} ({count} approved).", symbol="info") def _approve_packages( manifest: Path, - allow_exec: dict[str, dict[str, bool]], + user_scope: bool, + allow: dict[str, dict[str, bool]], + deny: dict[str, dict[str, bool]], packages: tuple[str, ...], ) -> None: """Approve specific packages by name.""" - from ..security.executables import save_user_approvals - declarations = _scan_installed_packages(manifest) decl_map = {d.package_name: d for d in declarations} decl_key_map = {d.package_key: d for d in declarations} @@ -201,16 +432,16 @@ def _approve_packages( _rich_info(f"{pkg}: no executable primitives to approve.", symbol="info") continue - allow_exec[decl.package_key] = {t: True for t in decl.exec_types} + allow[decl.package_key] = {t: True for t in decl.exec_types} _rich_success(f"Approved {decl.package_key}: {decl.summary_line()}") count += 1 if count > 0: - save_user_approvals(allow_exec) - _rich_info(f"Updated ~/.apm/approvals.yml ({count} approved).", symbol="info") + _save_store(manifest, user_scope, allow, deny) + _rich_info(f"Updated {_store_label(user_scope)} ({count} approved).", symbol="info") -def _scan_installed_packages(manifest: Path) -> list: +def scan_installed_executable_packages(manifest: Path) -> list: """Scan all installed packages under apm_modules/ for executables.""" from ..security.executables import ExecutableDeclaration, scan_package_executables @@ -250,6 +481,11 @@ def _scan_dir(base: Path) -> None: return results +def _scan_installed_packages(manifest: Path) -> list: + """Compatibility wrapper for tests and older imports.""" + return scan_installed_executable_packages(manifest) + + def _is_approved( allow_exec: dict[str, dict[str, bool]], decl, diff --git a/src/apm_cli/commands/marketplace/doctor.py b/src/apm_cli/commands/marketplace/doctor.py index 0f7b4f2b0..f33e374ef 100644 --- a/src/apm_cli/commands/marketplace/doctor.py +++ b/src/apm_cli/commands/marketplace/doctor.py @@ -21,6 +21,82 @@ ) +def _executable_trust_drift_check(project_root: Path) -> _DoctorCheck | None: + """Fleet-level executable-trust drift probe for ``apm doctor``. + + Flags packages whose project/user *allow* is overridden by the org + *deny* ceiling -- a governance conflict an admin should reconcile. Best + effort and informational: any failure to resolve degrades to ``None`` so + doctor never hangs or hard-fails on policy discovery. Points the operator + at ``apm policy explain `` for the per-package detail. + """ + apm_path = project_root / "apm.yml" + if not apm_path.is_file(): + return None + try: + from ...security.executables import ( + LAYER_ORG_DENY, + LAYER_ORG_DENY_ALL, + LAYER_PROJECT_ALLOW, + LAYER_USER_ALLOW, + build_exec_trust_context, + resolve_exec_decision, + ) + from ...utils.yaml_io import load_yaml + from ..approve import load_org_policy, scan_installed_executable_packages + + data = load_yaml(apm_path) + project_data = data if isinstance(data, dict) else {} + ctx = build_exec_trust_context( + policy=load_org_policy(project_root), project_data=project_data + ) + except Exception: + return None + + if not ctx.gate_enabled: + return _DoctorCheck( + name="executable trust", + passed=True, + detail="Gate disabled (no executables: block in apm.yml)", + informational=True, + ) + + org_deny_layers = (LAYER_ORG_DENY_ALL, LAYER_ORG_DENY) + allow_layers = (LAYER_PROJECT_ALLOW, LAYER_USER_ALLOW) + conflicts: list[str] = [] + try: + for decl in scan_installed_executable_packages(apm_path): + if not getattr(decl, "has_executables", False): + continue + for exec_type in decl.exec_types: + decision = resolve_exec_decision(ctx, decl.package_key, exec_type) + if decision.deciding_layer in org_deny_layers and any( + layer in decision.shadowed_layers for layer in allow_layers + ): + conflicts.append(decl.package_name) + break + except Exception: + return None + + if not conflicts: + return _DoctorCheck( + name="executable trust", + passed=True, + detail="No executable-trust layer conflicts", + informational=True, + ) + first = conflicts[0] + return _DoctorCheck( + name="executable trust", + passed=False, + detail=( + f"{len(conflicts)} package(s) allowed locally but denied by org policy " + f"(e.g. {first}). Run 'apm policy explain {first}' for detail." + ), + informational=True, + ) + + def run_doctor(verbose: bool, *, logger_name: str = "doctor") -> int: """Execute the doctor diagnostics and return an exit code. @@ -237,6 +313,10 @@ def run_doctor(verbose: bool, *, logger_name: str = "doctor") -> int: ) ) + drift_check = _executable_trust_drift_check(Path.cwd()) + if drift_check is not None: + checks.append(drift_check) + _render_doctor_table(logger, checks) # Exit: 0 if checks 1-2 pass; config checks are informational diff --git a/src/apm_cli/commands/policy.py b/src/apm_cli/commands/policy.py index 732a4b864..aec3cbeff 100644 --- a/src/apm_cli/commands/policy.py +++ b/src/apm_cli/commands/policy.py @@ -368,3 +368,22 @@ def status(policy_source, no_cache, as_json, output_format, check): if check and report["outcome"] != "found": sys.exit(1) sys.exit(0) + + +@policy.command( + "explain", + help="Explain the effective executable-trust decision for a package", +) +@click.argument("package") +def explain(package): + """Explain the effective executable-trust decision for PACKAGE. + + Prints, per executable type the package declares, whether it is allowed, + the deciding precedence layer (org / project / user), and any + lower-authority layers that decision shadowed. This is the per-package + companion to ``apm policy status`` (the policy-chain view) and the + fleet-level executable-trust drift check in ``apm doctor``. + """ + from .approve import explain_decision + + explain_decision(package) diff --git a/src/apm_cli/deps/lockfile.py b/src/apm_cli/deps/lockfile.py index f7cc597cf..a874cdbd4 100644 --- a/src/apm_cli/deps/lockfile.py +++ b/src/apm_cli/deps/lockfile.py @@ -23,6 +23,7 @@ _SELF_KEY = "." _ALLOWED_HOST_TYPES = {"gitlab"} +_ALLOWED_EXEC_STATUS = {"deployed", "gated_pending_approval", "denied", "absent"} def _normalize_lockfile_host_type(raw: Any) -> str | None: @@ -40,6 +41,21 @@ def _normalize_lockfile_host_type(raw: Any) -> str | None: return value +def _normalize_exec_status(raw: Any) -> str | None: + """Validate and normalize the optional executable-trust status.""" + if raw is None: + return None + if not isinstance(raw, str) or not raw.strip(): + raise ValueError("lockfile exec_status must be a non-empty string") + value = raw.strip() + if value not in _ALLOWED_EXEC_STATUS: + raise ValueError( + f"Unsupported lockfile exec_status: {raw}. Supported values: " + f"{', '.join(sorted(_ALLOWED_EXEC_STATUS))}" + ) + return value + + def _dedupe_preserving_order(values: list[str]) -> list[str]: """Return values without duplicates, preserving first-seen order.""" return list(dict.fromkeys(values)) @@ -104,6 +120,12 @@ class LockedDependency: # rather than stored as a sentinel, so absence stays distinguishable from # an explicit declaration. declared_license: str | None = None + # Resolved executable-trust state (issue #1873). One of ``deployed`` | + # ``gated_pending_approval`` | ``denied`` | ``absent``, mirroring the + # resolver ``trust_state``. Absence (``None``) means the package declared + # no executable primitive; it is OMITTED from the serialized entry so a + # never-gated package stays distinguishable from an explicitly-cleared one. + exec_status: str | None = None # Forward-compat carrier: keys we don't recognise are preserved # through a from_dict / to_dict round-trip so an older APM build # reading a lockfile written by a newer build doesn't silently drop @@ -203,6 +225,8 @@ def to_dict(self) -> dict[str, Any]: result["resolved_at"] = self.resolved_at if self.declared_license: result["declared_license"] = self.declared_license + if self.exec_status: + result["exec_status"] = self.exec_status # Replay forward-compat unknown fields LAST so they never shadow a # known field that this build understands. for k, v in self._unknown_fields.items(): @@ -238,6 +262,7 @@ def from_dict(cls, data: dict[str, Any]) -> LockedDependency: port = _p_int host_type = _normalize_lockfile_host_type(data.get("host_type")) + exec_status = _normalize_exec_status(data.get("exec_status")) # Recognised keys this build knows about. Anything else is captured # as ``_unknown_fields`` so a re-emit preserves forward-introduced @@ -276,6 +301,7 @@ def from_dict(cls, data: dict[str, Any]) -> LockedDependency: "resolved_tag", "resolved_at", "declared_license", + "exec_status", # legacy migration key handled above "deployed_skills", } @@ -314,6 +340,7 @@ def from_dict(cls, data: dict[str, Any]) -> LockedDependency: resolved_tag=data.get("resolved_tag"), resolved_at=data.get("resolved_at"), declared_license=data.get("declared_license"), + exec_status=exec_status, _unknown_fields=unknown_fields, ) diff --git a/src/apm_cli/install/context.py b/src/apm_cli/install/context.py index ad7e8ae57..7928ea9b1 100644 --- a/src/apm_cli/install/context.py +++ b/src/apm_cli/install/context.py @@ -15,7 +15,10 @@ from dataclasses import dataclass, field from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from apm_cli.security.executables import ExecTrustContext @dataclass @@ -154,6 +157,11 @@ class InstallContext: total_links_resolved: int = 0 # integrate direct_dep_failed: bool = False # integrate -- set when any direct dep fails blocked_executables: list[Any] = field(default_factory=list) # integrate + # #1873 executable-trust: the resolved trust context (built once per + # install) and the per-dependency lockfile exec_status computed at the gate. + exec_trust_ctx: ExecTrustContext | None = None # lazily built in template + exec_allow_map: dict[str, dict[str, bool]] | None = None # None means gate disabled + package_exec_status: dict[str, str] = field(default_factory=dict) # dep_key -> exec_status # ------------------------------------------------------------------ # policy_gate diff --git a/src/apm_cli/install/exec_gate.py b/src/apm_cli/install/exec_gate.py index b1e41a101..cc3888490 100644 --- a/src/apm_cli/install/exec_gate.py +++ b/src/apm_cli/install/exec_gate.py @@ -46,10 +46,19 @@ def check_executable_approval( ) # Build candidate keys: the dep-ref canonical key AND the name#version - # fallback so that approvals stored under either format are honoured. + # fallback so that approvals stored under either format are honoured. The + # version-blind name and the bare package name are also probed so that + # version-blind grants (org recommend, ``apm approve ``) match + # regardless of the installed version (#1873). pkg_key = resolve_package_key(package_info, package_name) candidate_keys = [pkg_key] + name_blind = pkg_key.split("#", 1)[0] + if name_blind not in candidate_keys: + candidate_keys.append(name_blind) + if package_name and package_name not in candidate_keys: + candidate_keys.append(package_name) + # Add name#version fallback when it differs from the primary key. _pkg = getattr(package_info, "package", None) if _pkg: @@ -58,6 +67,8 @@ def check_executable_approval( alt_key = build_approval_key(_name, _ver) if alt_key != pkg_key: candidate_keys.append(alt_key) + if _name and _name not in candidate_keys: + candidate_keys.append(_name) hooks_ok = any( is_package_approved(allow_executables, k, EXEC_TYPE_HOOKS) for k in candidate_keys @@ -68,8 +79,11 @@ def check_executable_approval( is_package_approved(allow_executables, k, EXEC_TYPE_CANVAS) for k in candidate_keys ) - # Track blocked packages for the post-loop approval prompt. - if ctx is not None and (not hooks_ok or not bin_ok or not mcp_ok or not canvas_ok): + # Track blocked packages for the post-loop approval prompt, and record the + # lockfile exec_status for the audit (Gap B) from the same scan. + blocked = not hooks_ok or not bin_ok or not mcp_ok or not canvas_ok + needs_status = ctx is not None and getattr(ctx, "exec_trust_ctx", None) is not None + if ctx is not None and (blocked or needs_status): from apm_cli.security.executables import scan_package_executables _install = Path(package_info.install_path) @@ -78,8 +92,16 @@ def check_executable_approval( if _pkg: _version = getattr(_pkg, "version", "") or "" _decl = scan_package_executables(_install, package_name, _version) - if _decl.has_executables: + if _decl.has_executables and blocked: ctx.blocked_executables.append(_decl) + if _decl.has_executables and needs_status: + from apm_cli.security.executables import exec_status_for_declaration + + status = exec_status_for_declaration( + ctx.exec_trust_ctx, candidate_keys, _decl.exec_types + ) + if status is not None: + ctx.package_exec_status[package_name] = status return hooks_ok, bin_ok, mcp_ok, canvas_ok diff --git a/src/apm_cli/install/phases/integrate.py b/src/apm_cli/install/phases/integrate.py index 969a7fe79..1f921c321 100644 --- a/src/apm_cli/install/phases/integrate.py +++ b/src/apm_cli/install/phases/integrate.py @@ -16,6 +16,8 @@ from __future__ import annotations import builtins +import os +import sys from pathlib import Path from typing import TYPE_CHECKING, Any @@ -468,33 +470,55 @@ def _run_executable_approval_prompt(ctx: InstallContext) -> None: After the integration loop, any package that had hooks or bin/ blocked is collected in ``ctx.blocked_executables``. This function runs the interactive approval flow (or hard-errors in CI) and - persists approved entries to ``~/.apm/approvals.yml`` (user-local, - never committed to source control) so the next install deploys them. + persists approved entries to the personal ``~/.apm/config.json`` + ``executables`` block (lowest authority, never committed to source + control) so the next install deploys them (#1873). """ if not ctx.blocked_executables: return + if os.environ.get("APM_NON_INTERACTIVE") or os.environ.get("CI") or not sys.stdin.isatty(): + first = ctx.blocked_executables[0].package_name + msg = ( + f"{len(ctx.blocked_executables)} package(s) have executable primitives " + "parked pending approval; install completed without deploying them." + ) + remedy = ( + f"Run 'apm policy explain {first}' for detail, then 'apm approve {first}' to trust it." + ) + if ctx.logger: + ctx.logger.warning(msg, symbol="warning") + ctx.logger.info(remedy, symbol="info") + else: + from apm_cli.core.command_logger import CommandLogger + + logger = CommandLogger("install") + logger.warning(msg, symbol="warning") + logger.info(remedy, symbol="info") + return + from apm_cli.security.executables import ( - load_user_approvals, + load_user_executables, prompt_executable_approval, - save_user_approvals, + save_user_executables, ) - # Seed the prompt with existing user-local approvals (not project entries, + # Seed the prompt with existing personal consent (not project entries, # which are read-only from the install pipeline's perspective). - allow_exec = load_user_approvals() or {} + allow_exec, deny_exec = load_user_executables() updated = prompt_executable_approval( ctx.blocked_executables, allow_executables=allow_exec, ) - # Persist new approvals to user-local file if user approved anything new. + # Persist new approvals to the personal config if the user approved + # anything new. if updated and updated != allow_exec: - save_user_approvals(updated) + save_user_executables(updated, deny_exec) if ctx.logger: ctx.logger.info( - "Updated ~/.apm/approvals.yml. " + "Updated ~/.apm/config.json. " "Run 'apm install' again to deploy approved executables.", symbol="info", ) diff --git a/src/apm_cli/install/phases/lockfile.py b/src/apm_cli/install/phases/lockfile.py index 735b096e4..666f60dba 100644 --- a/src/apm_cli/install/phases/lockfile.py +++ b/src/apm_cli/install/phases/lockfile.py @@ -91,6 +91,8 @@ def build_and_save(self) -> None: # Attach deployed_files and package_type to each LockedDependency self._attach_deployed_files(lockfile) self._attach_package_types(lockfile) + # Attach #1873 executable trust state captured at the gate. + self._attach_exec_status(lockfile) # Apply CLI --skill override to lockfile entries (skill_bundle only) self._attach_skill_subset_override(lockfile) # Attach content hashes captured at download/verify time @@ -170,6 +172,22 @@ def _attach_package_types(self, lockfile: LockFile) -> None: if dep_key in lockfile.dependencies: lockfile.dependencies[dep_key].package_type = pkg_type + def _attach_exec_status(self, lockfile: LockFile) -> None: + """Attach the #1873 ``exec_status`` trust state computed at the gate. + + ``ctx.package_exec_status`` is keyed by dep_key and holds the worst-case + trust state (``deployed`` / ``gated_pending_approval`` / ``denied``) for + each dependency that declared executables. Packages with no executables + are absent from the map and keep ``exec_status=None`` (the audit treats + them as trusted). + """ + statuses = getattr(self.ctx, "package_exec_status", None) + if not statuses: + return + for dep_key, status in statuses.items(): + if dep_key in lockfile.dependencies and status: + lockfile.dependencies[dep_key].exec_status = status + def _attach_skill_subset_override(self, lockfile: LockFile) -> None: """Apply CLI --skill override to lockfile skill_bundle entries. diff --git a/src/apm_cli/install/template.py b/src/apm_cli/install/template.py index aad90c72a..22cf606b7 100644 --- a/src/apm_cli/install/template.py +++ b/src/apm_cli/install/template.py @@ -19,17 +19,54 @@ def _effective_allow(ctx) -> dict | None: - """Return the effective allowExecutables map for the install context. + """Return the effective (deny-wins) allow-map for the install context. - Reads the gate opt-in signal from the project ``apm.yml`` via - ``ctx.apm_package.allow_executables`` and merges it with the - user-local approvals from ``~/.apm/approvals.yml``. Returns - ``None`` when the gate is disabled (backward-compatible behaviour). - """ - from apm_cli.security.executables import effective_allow_executables + Builds the #1873 trust context from three layers and materialises the + decision map via the shared resolver: + + * org policy -- ``ctx.policy_fetch.policy`` (the deny ceiling, Gap A); + * project ``apm.yml`` -- the ``executables`` block (or legacy + ``allowExecutables`` alias) read from disk; + * user consent -- ``~/.apm/config.json`` (lowest authority). - project_val = getattr(getattr(ctx, "apm_package", None), "allow_executables", None) - return effective_allow_executables(project_val) + Returns ``None`` when the gate is disabled (backward-compatible: every + executable deploys). + """ + from apm_cli.security.executables import ( + build_exec_trust_context, + materialize_exec_map, + ) + from apm_cli.utils.yaml_io import load_yaml + + if getattr(ctx, "exec_trust_ctx", None) is not None: + return getattr(ctx, "exec_allow_map", None) + + project_data: dict | None = None + manifest = getattr(ctx, "project_root", None) + if manifest is not None: + manifest_path = manifest / "apm.yml" + if manifest_path.is_file(): + data = load_yaml(manifest_path) + if isinstance(data, dict): + project_data = data + + # Fall back to the in-memory gate signal when apm.yml is unreadable so a + # project that opted in via allowExecutables still gates. + if project_data is None: + project_val = getattr(getattr(ctx, "apm_package", None), "allow_executables", None) + if isinstance(project_val, dict): + project_data = {"allowExecutables": project_val} + + policy = getattr(getattr(ctx, "policy_fetch", None), "policy", None) + trust_ctx = build_exec_trust_context(policy=policy, project_data=project_data) + allow_map = materialize_exec_map(trust_ctx) + # Cache the resolved context and allow map once per install so each + # dependency uses the same precedence ladder without re-reading policy files. + if hasattr(ctx, "exec_trust_ctx"): + ctx.exec_trust_ctx = trust_ctx + if hasattr(ctx, "exec_allow_map"): + ctx.exec_allow_map = allow_map + return allow_map def run_integration_template( diff --git a/src/apm_cli/policy/inheritance.py b/src/apm_cli/policy/inheritance.py index 1b3abedd8..e0e0fc60e 100644 --- a/src/apm_cli/policy/inheritance.py +++ b/src/apm_cli/policy/inheritance.py @@ -19,6 +19,7 @@ CompilationStrategyPolicy, CompilationTargetPolicy, DependencyPolicy, + ExecutablesPolicy, IntegrityPolicy, ManifestPolicy, McpPolicy, @@ -73,6 +74,7 @@ def merge_policies(parent: ApmPolicy, child: ApmPolicy) -> ApmPolicy: registry_source=_merge_registry_source(parent.registry_source, child.registry_source), security=_merge_security(parent.security, child.security), bin_deploy=_merge_bin_deploy(parent.bin_deploy, child.bin_deploy), + executables=_merge_executables(parent.executables, child.executables), ) @@ -175,6 +177,24 @@ def _merge_bin_deploy(parent: BinDeployPolicy, child: BinDeployPolicy) -> BinDep ) +def _merge_executables(parent: ExecutablesPolicy, child: ExecutablesPolicy) -> ExecutablesPolicy: + """Merge the executable-trust policy block: tighten-only ratchet (#1873). + + ``deny_all`` OR-merges so any ancestor's kill-switch (``True``) sticks; + ``deny``/``require``/``recommend``/``enforce`` union-merge so a child can + add packages but never drop a parent's. ``recommend`` growing the vetted + set is safe (it is user-overridable); ``enforce`` is union-merged for v2 + but in v1 the resolver degrades it to ``recommend`` with no force path. + """ + return ExecutablesPolicy( + deny_all=parent.deny_all or child.deny_all, + deny=_union(parent.deny, child.deny), + require=_union(parent.require, child.require), + recommend=_union(parent.recommend, child.recommend), + enforce=_union(parent.enforce, child.enforce), + ) + + def _merge_cache(parent: PolicyCache, child: PolicyCache) -> PolicyCache: return PolicyCache(ttl=min(parent.ttl, child.ttl)) diff --git a/src/apm_cli/policy/models.py b/src/apm_cli/policy/models.py index 408d2e2da..366875fed 100644 --- a/src/apm_cli/policy/models.py +++ b/src/apm_cli/policy/models.py @@ -20,6 +20,7 @@ "dependency-denylist": "apm.yml", "required-packages": "apm.yml", "required-packages-deployed": "apm.lock.yaml", + "required-executable-untrusted": "apm.lock.yaml", "required-package-version": "apm.lock.yaml", "transitive-depth": "apm.lock.yaml", "mcp-allowlist": "apm.yml", diff --git a/src/apm_cli/policy/parser.py b/src/apm_cli/policy/parser.py index 538408483..8d4f76869 100644 --- a/src/apm_cli/policy/parser.py +++ b/src/apm_cli/policy/parser.py @@ -16,6 +16,7 @@ CompilationStrategyPolicy, CompilationTargetPolicy, DependencyPolicy, + ExecutablesPolicy, IntegrityPolicy, ManifestPolicy, McpPolicy, @@ -53,6 +54,7 @@ "unmanaged_files", "security", "bin_deploy", + "executables", } @@ -210,9 +212,38 @@ def validate_policy(data: dict) -> tuple[list[str], list[str]]: f"security.integrity.require_hashes must be a boolean, got '{require_hashes}'" ) + # executables (issue #1873, Gap A): org grant/deny trust block. + _validate_executables(data, errors, warnings) + return errors, warnings +def _validate_executables(data: dict, errors: list[str], warnings: list[str]) -> None: + """Validate the ``executables:`` block and warn on deprecated ``bin_deploy``.""" + if data.get("bin_deploy") is not None: + warnings.append( + "'bin_deploy' is deprecated; use 'executables' (deny_all/deny) instead. " + "bin_deploy is still honored as a bin-scoped deny alias for one minor cycle." + ) + + execs = data.get("executables") + if execs is None: + return + if not isinstance(execs, dict): + errors.append("executables must be a YAML mapping") + return + deny_all = execs.get("deny_all") + if deny_all is not None and not isinstance(deny_all, bool): + errors.append(f"executables.deny_all must be a boolean, got '{deny_all}'") + for key in ("deny", "require", "recommend", "enforce"): + val = execs.get(key) + if val is not None and not isinstance(val, list): + errors.append( + f"executables.{key} must be a YAML list of package strings " + f"(got {type(val).__name__})" + ) + + def _build_policy(data: dict) -> ApmPolicy: """Build an ApmPolicy from a validated dict.""" if not data: @@ -327,6 +358,17 @@ def _build_policy(data: dict) -> ApmPolicy: deny=_parse_tuple(bd_data.get("deny")) if bd_data.get("deny") is not None else (), ) + ex_data = data.get("executables") or {} + executables = ExecutablesPolicy( + deny_all=bool(ex_data.get("deny_all", False)), + deny=_parse_tuple(ex_data.get("deny")) if ex_data.get("deny") is not None else (), + require=_parse_tuple(ex_data.get("require")) if ex_data.get("require") is not None else (), + recommend=_parse_tuple(ex_data.get("recommend")) + if ex_data.get("recommend") is not None + else (), + enforce=_parse_tuple(ex_data.get("enforce")) if ex_data.get("enforce") is not None else (), + ) + return ApmPolicy( name=data.get("name", "") or "", version=data.get("version", "") or "", @@ -342,6 +384,7 @@ def _build_policy(data: dict) -> ApmPolicy: registry_source=registry_source, security=security, bin_deploy=bin_deploy, + executables=executables, ) diff --git a/src/apm_cli/policy/policy_checks.py b/src/apm_cli/policy/policy_checks.py index a757414ac..9936010b6 100644 --- a/src/apm_cli/policy/policy_checks.py +++ b/src/apm_cli/policy/policy_checks.py @@ -177,7 +177,16 @@ def _check_required_packages_deployed( lock: LockFile | None, policy: DependencyPolicy, ) -> CheckResult: - """Check 4: required packages appear in lockfile with deployed files.""" + """Check 4: required packages are PRESENT in the lockfile (issue #1873, Gap B). + + Asserts package PRESENCE, not materialised ``deployed_files``. A package + can be legitimately present-but-parked -- resolved and locked, with its + executable primitives gated pending approval (``exec_status = + gated_pending_approval``) and therefore zero deployed files yet. The old + ``not locked.deployed_files`` test mis-fired on exactly that healthy state + and blocked installs that should succeed. Executable trust is now audited + separately by ``required-executable-untrusted``. + """ if not policy.effective_require or lock is None: return CheckResult( name="required-packages-deployed", @@ -187,32 +196,88 @@ def _check_required_packages_deployed( dep_names = {dep.get_canonical_dependency_string().split("#")[0] for dep in deps} lock_by_name = {locked.get_unique_key(): locked for _key, locked in lock.dependencies.items()} - not_deployed: list[str] = [] + not_present: list[str] = [] for req in policy.effective_require: pkg_name = req.split("#")[0] if pkg_name not in dep_names: continue # not in manifest -- check 3 handles this - # Find in lockfile by exact key match - locked = lock_by_name.get(pkg_name) - if not locked or not locked.deployed_files: - not_deployed.append(pkg_name) + # PRESENCE, not deployment: the package must appear in the lockfile. + if lock_by_name.get(pkg_name) is None: + not_present.append(pkg_name) - if not not_deployed: + if not not_present: return CheckResult( name="required-packages-deployed", passed=True, - message="All required packages deployed", + message="All required packages present in lockfile", ) return CheckResult( name="required-packages-deployed", passed=False, message=( - f"{len(not_deployed)} required package(s) not deployed. " + f"{len(not_present)} required package(s) absent from the lockfile. " "Hint: run `apm install --no-policy` to repair the lockfile, " "then reinstall normally." ), - details=not_deployed, + details=not_present, + ) + + +def _check_required_executable_untrusted( + deps: list[DependencyReference], + lock: LockFile | None, + exec_policy: ExecutablesPolicy, +) -> CheckResult: + """Check 4b: required-executable packages must be TRUSTED, not parked. + + For every package in the org ``executables.require`` set, the lockfile's + ``exec_status`` must be ``deployed``. A present-but-parked package + (``gated_pending_approval``) or a denied one is a hard CI failure here -- + the install itself SUCCEEDS so a developer can self-approve, but a fleet + that mandates the executable cannot ship it untrusted (issue #1873). + """ + required = tuple(exec_policy.require or ()) + if not required or lock is None: + return CheckResult( + name="required-executable-untrusted", + passed=True, + message="No required executables to verify", + ) + + from ..security.executables import TRUST_DEPLOYED + + dep_names = {dep.get_canonical_dependency_string().split("#")[0] for dep in deps} + lock_by_name = {locked.get_unique_key(): locked for _key, locked in lock.dependencies.items()} + untrusted: list[str] = [] + for req in required: + pkg_name = req.split("#")[0] + if pkg_name not in dep_names: + continue # presence is audited by required-packages / -deployed + locked = lock_by_name.get(pkg_name) + # Trusted when exec_status is absent (no executables declared) or when + # the executable gate recorded a deployed state. Gated or denied + # required executables are untrusted. + if locked is None or ( + locked.exec_status is not None and locked.exec_status != TRUST_DEPLOYED + ): + untrusted.append(pkg_name) + + if not untrusted: + return CheckResult( + name="required-executable-untrusted", + passed=True, + message="All required executables are trusted", + ) + return CheckResult( + name="required-executable-untrusted", + passed=False, + message=( + f"{len(untrusted)} required executable(s) present but untrusted. " + "Approve them to deploy: `apm approve --recommended` (org-vetted set) " + "or `apm approve ` per package." + ), + details=untrusted, ) @@ -1124,6 +1189,8 @@ def _run(check: CheckResult) -> bool: return result if _run(_check_required_packages_deployed(deps_list, lockfile, policy.dependencies)): return result + if _run(_check_required_executable_untrusted(deps_list, lockfile, policy.executables)): + return result if _run(_check_required_package_version(deps_list, lockfile, policy.dependencies)): return result if _run(_check_transitive_depth(lockfile, policy.dependencies)): diff --git a/src/apm_cli/policy/schema.py b/src/apm_cli/policy/schema.py index 8246a3e9d..ed4daa4df 100644 --- a/src/apm_cli/policy/schema.py +++ b/src/apm_cli/policy/schema.py @@ -237,6 +237,39 @@ class BinDeployPolicy: deny: tuple[str, ...] = () +@dataclass(frozen=True) +class ExecutablesPolicy: + """Org policy controls for executable-primitive trust (issue #1873, Gap A). + + Unifies the executable-trust vocabulary onto one noun, ``executables``, + and closes the GRANT asymmetry: ``apm-policy.yml`` could previously only + DENY executables (via the deprecated ``bin_deploy`` block, bin-scoped), + never GRANT or recommend them fleet-wide. + + All five fields ratchet tighten-only through inheritance + (:func:`~apm_cli.policy.inheritance._merge_executables`): + + * ``deny_all`` -- kill-switch denying every executable type for all + packages (OR-merged: any ancestor's ``True`` sticks). + * ``deny`` -- package canonical strings denied for ALL exec types + (union-merged; absolute ceiling on DENY). + * ``require`` -- packages whose executables are mandated PRESENT; the + ``required-executable-untrusted`` audit hard-fails CI when a required + package is present-but-parked (union-merged). + * ``recommend`` -- org-vetted set; default-allow but user-overridable + (union-merged). Bulk-accepted via ``apm approve --recommended``. + * ``enforce`` -- v2 mandate tier (force-execute when provenance is + verified). In v1 this NEVER force-executes: it fail-safe degrades to + ``recommend`` with no force path (union-merged, known-but-degraded). + """ + + deny_all: bool = False + deny: tuple[str, ...] = () + require: tuple[str, ...] = () + recommend: tuple[str, ...] = () + enforce: tuple[str, ...] = () # v2 mandate tier; v1 degrades to recommend + + @dataclass(frozen=True) class ApmPolicy: """Top-level APM policy model.""" @@ -254,4 +287,9 @@ class ApmPolicy: unmanaged_files: UnmanagedFilesPolicy = field(default_factory=UnmanagedFilesPolicy) registry_source: RegistrySourcePolicy = field(default_factory=RegistrySourcePolicy) security: SecurityPolicy = field(default_factory=SecurityPolicy) + # ``bin_deploy`` is the DEPRECATED bin-scoped predecessor of + # ``executables`` (issue #1873). Retained one minor cycle as an alias the + # resolver still honors for the ``bin`` exec type; new policies use + # ``executables:`` instead. bin_deploy: BinDeployPolicy = field(default_factory=BinDeployPolicy) + executables: ExecutablesPolicy = field(default_factory=ExecutablesPolicy) diff --git a/src/apm_cli/security/executables.py b/src/apm_cli/security/executables.py index 6786a15f7..de7f0e6ea 100644 --- a/src/apm_cli/security/executables.py +++ b/src/apm_cli/security/executables.py @@ -149,6 +149,194 @@ def is_any_type_approved( return any(entry.get(t, False) for t in ALL_EXEC_TYPES) +# ------------------------------------------------------------------- +# Unified executable-trust resolver (issue #1873) +# ------------------------------------------------------------------- +# +# One deny-wins, first-match-wins precedence ladder, shared by the +# install gate AND the policy audit so the two never guess independently. + +# trust_state values (also the lockfile exec_status field domain). +TRUST_DEPLOYED = "deployed" # allowed and (will be) materialised +TRUST_GATED = "gated_pending_approval" # not yet approved; approvable +TRUST_DENIED = "denied" # an explicit deny rule forbids it +TRUST_ABSENT = "absent" # package not present at all (audit-only) + +# deciding_layer labels (which rung of the ladder decided). +LAYER_GATE_DISABLED = "gate-disabled" +LAYER_ORG_DENY_ALL = "org-deny-all" +LAYER_ORG_DENY = "org-deny" +LAYER_USER_DENY = "user-deny" +LAYER_PROJECT_DENY = "project-deny" +LAYER_ENFORCE_DEGRADED = "org-enforce-degraded" # v2 mandate, v1 fail-safe +LAYER_PROJECT_ALLOW = "project-allow" +LAYER_USER_ALLOW = "user-allow" +LAYER_ORG_RECOMMEND = "org-recommend" +LAYER_DEFAULT_DENY = "default-deny" + + +@dataclass(frozen=True) +class ExecDecision: + """The resolved trust decision for one (package, exec_type) pair. + + Attributes: + allowed: Whether the executable may run / be materialised. + deciding_layer: Which precedence rung decided (one of the + ``LAYER_*`` constants) -- surfaced by ``apm policy explain``. + trust_state: One of ``TRUST_*`` for the lockfile ``exec_status``. + shadowed_layers: Lower-authority layers that held a contrary + opinion but were overridden (for ``apm policy explain`` honesty). + """ + + allowed: bool + deciding_layer: str + trust_state: str + shadowed_layers: tuple[str, ...] = () + + +@dataclass(frozen=True) +class ExecTrustContext: + """Resolved trust inputs across the org / project / user layers. + + The org fields are package-name sets (version-blind, mirroring the + package-level ``bin_deploy`` semantics). ``org_bin_deny*`` carry the + DEPRECATED ``bin_deploy`` block, honored as a ``bin``-scoped deny + alias for one minor cycle. The project / user maps keep the granular + ``{package_key: {exec_type: bool}}`` shape. + """ + + gate_enabled: bool + org_deny_all: bool + org_deny: frozenset[str] + org_require: frozenset[str] + org_recommend: frozenset[str] + org_enforce: frozenset[str] + org_bin_deny_all: bool + org_bin_deny: frozenset[str] + project_allow: dict[str, dict[str, bool]] + project_deny: dict[str, dict[str, bool]] + user_allow: dict[str, dict[str, bool]] + user_deny: dict[str, dict[str, bool]] + + +def _strip_version(package_key: str) -> str: + """Return the version-blind canonical name from an approval key.""" + return package_key.split("#", 1)[0] + + +def _map_grants( + grant_map: dict[str, dict[str, bool]] | None, + package_key: str, + exec_type: str, +) -> bool: + """Return True if *grant_map* grants *exec_type* for *package_key*. + + Matches the exact key, the version-blind name, or any stored key that + shares the same version-blind name -- so approving ``owner/repo`` + covers ``owner/repo#v1`` and vice-versa. + """ + if not grant_map: + return False + name = _strip_version(package_key) + for stored_key, entry in grant_map.items(): + if not isinstance(entry, dict): + continue + if (stored_key in (package_key, name) or _strip_version(stored_key) == name) and bool( + entry.get(exec_type, False) + ): + return True + return False + + +def _org_denies(ctx: ExecTrustContext, name: str, exec_type: str) -> tuple[bool, str | None]: + """Return ``(denied, layer)`` for the org DENY ceiling (rule 1).""" + if ctx.org_deny_all: + return True, LAYER_ORG_DENY_ALL + if exec_type == EXEC_TYPE_BIN and ctx.org_bin_deny_all: + return True, LAYER_ORG_DENY_ALL + if name in ctx.org_deny: + return True, LAYER_ORG_DENY + if exec_type == EXEC_TYPE_BIN and name in ctx.org_bin_deny: + return True, LAYER_ORG_DENY + return False, None + + +def resolve_exec_decision( + ctx: ExecTrustContext, + package_key: str, + exec_type: str, +) -> ExecDecision: + """Resolve the trust decision for one (package, exec_type) pair. + + Implements the #1873 deny-wins, first-match-wins ladder: + + 1. ORG deny_all / deny -> DENIED (absolute ceiling) + 2. USER deny -> DENIED (narrowing) + PROJECT deny -> DENIED (committed narrowing) + 3/4. ORG enforce -> v1 fail-safe degrade to recommend + 5. PROJECT allow -> ALLOWED + 6. USER allow -> ALLOWED + 7. ORG recommend -> ALLOWED (user-overridable) + 8. (no match) -> DENIED, secure-by-default (approvable) + + v1 NEVER force-executes: ``enforce`` carries no provenance check and + degrades to ``recommend`` so it stays overridable by a USER deny. + """ + if not ctx.gate_enabled: + return ExecDecision(True, LAYER_GATE_DISABLED, TRUST_DEPLOYED) + + name = _strip_version(package_key) + + # 1. ORG deny ceiling (absolute). + denied, layer = _org_denies(ctx, name, exec_type) + if denied: + return ExecDecision(False, layer, TRUST_DENIED, _shadowed_grants(ctx, name, exec_type)) + + # 2. USER deny / PROJECT deny (narrowing; both win over any grant). + if _map_grants(ctx.user_deny, package_key, exec_type): + return ExecDecision( + False, LAYER_USER_DENY, TRUST_DENIED, _shadowed_grants(ctx, name, exec_type) + ) + if _map_grants(ctx.project_deny, package_key, exec_type): + return ExecDecision( + False, LAYER_PROJECT_DENY, TRUST_DENIED, _shadowed_grants(ctx, name, exec_type) + ) + + enforce_active = name in ctx.org_enforce + + # 5. PROJECT allow (overridable only by an upstream deny, handled above). + if _map_grants(ctx.project_allow, package_key, exec_type): + return ExecDecision(True, LAYER_PROJECT_ALLOW, TRUST_DEPLOYED) + + # 6. USER allow. + if _map_grants(ctx.user_allow, package_key, exec_type): + return ExecDecision(True, LAYER_USER_ALLOW, TRUST_DEPLOYED) + + # 7. ORG recommend (or degraded enforce). Both default-allow, overridable. + if name in ctx.org_recommend or enforce_active: + layer = ( + LAYER_ENFORCE_DEGRADED + if enforce_active and name not in ctx.org_recommend + else LAYER_ORG_RECOMMEND + ) + return ExecDecision(True, layer, TRUST_DEPLOYED) + + # 8. Secure-by-default: denied but approvable (gated, not hard-denied). + return ExecDecision(False, LAYER_DEFAULT_DENY, TRUST_GATED) + + +def _shadowed_grants(ctx: ExecTrustContext, name: str, exec_type: str) -> tuple[str, ...]: + """Return lower-authority grant layers overridden by a deny decision.""" + shadowed: list[str] = [] + if _map_grants(ctx.project_allow, name, exec_type): + shadowed.append(LAYER_PROJECT_ALLOW) + if _map_grants(ctx.user_allow, name, exec_type): + shadowed.append(LAYER_USER_ALLOW) + if name in ctx.org_recommend or name in ctx.org_enforce: + shadowed.append(LAYER_ORG_RECOMMEND) + return tuple(shadowed) + + # ------------------------------------------------------------------- # Approval key construction # ------------------------------------------------------------------- @@ -524,26 +712,113 @@ def save_user_approvals(approvals: dict[str, dict[str, bool]]) -> None: os.chmod(path, 0o600) +def materialize_exec_map(ctx: ExecTrustContext) -> dict[str, dict[str, bool]] | None: + """Materialise the deny-wins effective allow-map from a trust context. + + Returns ``None`` when the gate is disabled; otherwise every candidate + package key is run through :func:`resolve_exec_decision` and only ALLOWED + ``(key, exec_type)`` pairs are emitted, each also under its version-blind + name so the gate's exact-membership lookup matches any installed version. + """ + if not ctx.gate_enabled: + return None + + candidate_keys: set[str] = set() + candidate_keys |= set(ctx.project_allow) | set(ctx.project_deny) + candidate_keys |= set(ctx.user_allow) | set(ctx.user_deny) + candidate_keys |= set(ctx.org_recommend) | set(ctx.org_deny) | set(ctx.org_enforce) + candidate_keys |= set(ctx.org_bin_deny) + + result: dict[str, dict[str, bool]] = {} + for key in candidate_keys: + for exec_type in ALL_EXEC_TYPES: + if not resolve_exec_decision(ctx, key, exec_type).allowed: + continue + result.setdefault(key, {})[exec_type] = True + name = _strip_version(key) + if name != key: + result.setdefault(name, {})[exec_type] = True + return result + + +def exec_status_for_declaration( + ctx: ExecTrustContext, + candidate_keys: list[str], + exec_types: tuple[str, ...], +) -> str | None: + """Return the lockfile ``exec_status`` for a package's declared executables. + + Resolves every declared exec type across the candidate keys and folds the + decisions into ONE worst-case trust state for the lockfile field: + + * any declared type hard-DENIED -> ``denied`` + * else any declared type not allowed -> ``gated_pending_approval`` + * else (all declared types allowed) -> ``deployed`` + + Returns ``None`` when the package declares no executables (the audit then + treats it as trusted) or when the gate is disabled. + """ + if not exec_types or not ctx.gate_enabled: + return None + + worst = TRUST_DEPLOYED + for exec_type in exec_types: + best = None + for key in candidate_keys: + decision = resolve_exec_decision(ctx, key, exec_type) + if decision.allowed: + best = TRUST_DEPLOYED + break + # Prefer the more severe of denied/gated across candidate keys. + if decision.trust_state == TRUST_DENIED: + best = TRUST_DENIED + elif best is None: + best = TRUST_GATED + if best == TRUST_DENIED: + return TRUST_DENIED + if best == TRUST_GATED: + worst = TRUST_GATED + return worst + + +def build_effective_exec_map( + *, + policy: Any | None, + project_data: dict[str, Any] | None, +) -> dict[str, dict[str, bool]] | None: + """Materialise the deny-wins effective allow-map consumed by the install gate. + + This is the #1873 replacement for the legacy ``{**project, **user}`` + user-wins merge. See :func:`materialize_exec_map` for the emission rules. + + Returns ``None`` when the gate is disabled (backward-compatible: every + executable deploys), mirroring :attr:`ExecTrustContext.gate_enabled`. + """ + ctx = build_exec_trust_context(policy=policy, project_data=project_data) + return materialize_exec_map(ctx) + + def effective_allow_executables( project_allow_executables: dict[str, dict[str, bool]] | None, ) -> dict[str, dict[str, bool]] | None: - """Return the effective allowExecutables map for an install run. - - Merges the project-level gate signal with the user-local approvals: - - - Returns ``None`` when the project has no ``allowExecutables`` block - (gate disabled -- backward-compatible behaviour, all executables - deployed). - - Returns a merged dict when the gate is enabled: project-level entries - (retained for CI / automated pipelines) are overlaid with user-local - approvals from ``~/.apm/approvals.yml``. User approvals take - precedence so an ``apm approve`` decision is always honoured even if the - project entry is absent or stale. + """Return the effective allow-map for an install run (deny-wins). + + Back-compat shim around :func:`build_effective_exec_map`: the historical + ``{**project, **user}`` user-wins merge is replaced by the #1873 deny-wins + precedence. Callers that only have the legacy ``allowExecutables`` block + (no org policy) reach the resolver through here; the install template uses + :func:`build_effective_exec_map` directly so the org-deny ceiling applies. + + Returns ``None`` when the gate is disabled (no block), preserving the + backward-compatible "deploy everything" behaviour. """ - if project_allow_executables is None: - return None - user = load_user_approvals() - return {**project_allow_executables, **user} + if isinstance(project_allow_executables, dict): + data: dict[str, Any] = {"allowExecutables": project_allow_executables} + else: + # ``allow_executables`` is ``dict | None`` by contract; any other shape + # (an absent/unparsed in-memory signal) means "no project layer". + data = {} + return build_effective_exec_map(policy=None, project_data=data) def filter_mcp_by_allow_executables( @@ -593,3 +868,287 @@ def read_bundle_allow_executables(apm_yml_path: Path, logger: Any) -> dict | Non symbol="warning", ) return {} + + +# ------------------------------------------------------------------- +# Unified vocabulary layer (issue #1873): one noun ``executables`` +# ------------------------------------------------------------------- +# +# Project apm.yml: ``executables: {allow, deny}`` (the deprecated +# ``allowExecutables`` block remains a read alias for one minor cycle). +# Personal consent: ``~/.apm/config.json`` under ``executables:{allow,deny}`` +# (lowest authority). The standalone ``~/.apm/approvals.yml`` is migrated on +# first read and DELETED -- net-new control-surface files = 0. + + +def _parse_grant_block( + raw: Any, + *, + where: str, +) -> dict[str, dict[str, bool]]: + """Validate and normalise a ``{package_key: {exec_type: bool}}`` map.""" + if raw is None: + return {} + if not isinstance(raw, dict): + raise ValueError( + f"{where} must be a mapping of package keys to " + "{hooks: bool, mcp: bool, bin: bool, canvas: bool}" + ) + result: dict[str, dict[str, bool]] = {} + for pkg_key, entry in raw.items(): + if not isinstance(pkg_key, str): + raise ValueError(f"{where} key must be a string, got {type(pkg_key).__name__}") + if not isinstance(entry, dict): + raise ValueError( + f"{where}[{pkg_key!r}] must be a mapping of exec types to " + f"booleans, got {type(entry).__name__}" + ) + parsed: dict[str, bool] = {} + for exec_type, value in entry.items(): + exec_type_str = str(exec_type) + if exec_type_str not in ALL_EXEC_TYPES: + raise ValueError( + f"{where}[{pkg_key!r}]: unknown exec type {exec_type_str!r} " + f"(valid: {', '.join(ALL_EXEC_TYPES)})" + ) + if not isinstance(value, bool): + raise ValueError( + f"{where}[{pkg_key!r}][{exec_type_str!r}] must be a boolean, " + f"got {type(value).__name__}" + ) + parsed[exec_type_str] = value + result[pkg_key] = parsed + return result + + +def parse_project_executables( + data: dict[str, Any], +) -> tuple[dict[str, dict[str, bool]], dict[str, dict[str, bool]], bool]: + """Parse the project ``executables`` block from raw apm.yml data. + + Returns ``(allow, deny, used_deprecated_alias)``. The deprecated + ``allowExecutables`` block is folded into ``allow`` (the new + ``executables.allow`` wins on a per-key conflict); when it is present the + boolean flag is ``True`` so callers can emit one deprecation warning. + """ + used_alias = False + allow: dict[str, dict[str, bool]] = {} + + alias_raw = data.get("allowExecutables") + if alias_raw is not None: + used_alias = True + allow.update(_parse_grant_block(alias_raw, where="allowExecutables")) + + deny: dict[str, dict[str, bool]] = {} + block = data.get("executables") + if block is not None: + if not isinstance(block, dict): + raise ValueError("executables must be a mapping with 'allow' and/or 'deny' keys") + allow.update(_parse_grant_block(block.get("allow"), where="executables.allow")) + deny = _parse_grant_block(block.get("deny"), where="executables.deny") + + return allow, deny, used_alias + + +def project_executables_gate_enabled(data: dict[str, Any]) -> bool: + """Return True when the project opts into the gate (any block present).""" + return data.get("executables") is not None or data.get("allowExecutables") is not None + + +def _user_config_file() -> Path: + """Return the path to the user-local JSON config (override seam in tests).""" + from .. import config + + return Path(config.CONFIG_FILE) + + +def _legacy_approvals_path() -> Path: + """Return the path to the deprecated ``~/.apm/approvals.yml`` store.""" + return get_user_approvals_path() + + +def _migrate_legacy_approvals(allow: dict[str, dict[str, bool]]) -> dict[str, dict[str, bool]]: + """Fold a legacy ``approvals.yml`` into *allow* and delete the file. + + The legacy file stored a bare ``{package_key: {exec_type: bool}}`` map of + grants. Existing config entries win over legacy entries on conflict. + """ + import contextlib + + legacy = _legacy_approvals_path() + if not legacy.is_file(): + return allow + from ..utils.yaml_io import load_yaml + + legacy_data = load_yaml(legacy) + if isinstance(legacy_data, dict): + for pkg_key, entry in legacy_data.items(): + if isinstance(entry, dict): + merged = {**{k: bool(v) for k, v in entry.items()}, **allow.get(pkg_key, {})} + allow[pkg_key] = merged + with contextlib.suppress(OSError): + legacy.unlink() + return allow + + +def load_user_executables() -> tuple[dict[str, dict[str, bool]], dict[str, dict[str, bool]]]: + """Load personal executable consent from ``~/.apm/config.json``. + + Returns ``(allow, deny)``. On first read, any legacy + ``~/.apm/approvals.yml`` is folded into ``allow`` and deleted, and the + migrated state is persisted back to the config so the fold happens once. + """ + import json + + cfg_path = _user_config_file() + cfg: dict[str, Any] = {} + if cfg_path.is_file(): + try: + cfg = json.loads(cfg_path.read_text(encoding="utf-8")) or {} + except (OSError, ValueError): + cfg = {} + section = cfg.get("executables") if isinstance(cfg.get("executables"), dict) else {} + allow = dict(section.get("allow") or {}) + deny = dict(section.get("deny") or {}) + + migrated = _migrate_legacy_approvals(allow) + if migrated != allow or (allow and "executables" not in cfg): + allow = migrated + save_user_executables(allow, deny) + else: + allow = migrated + return allow, deny + + +def save_user_executables( + allow: dict[str, dict[str, bool]], + deny: dict[str, dict[str, bool]], +) -> None: + """Persist personal executable consent into ``~/.apm/config.json``. + + The config file is written owner-only (``0o600``) to keep the consent + list private on shared systems. + """ + import contextlib + import json + + cfg_path = _user_config_file() + cfg: dict[str, Any] = {} + if cfg_path.is_file(): + try: + cfg = json.loads(cfg_path.read_text(encoding="utf-8")) or {} + except (OSError, ValueError): + cfg = {} + cfg["executables"] = {"allow": allow, "deny": deny} + cfg_path.parent.mkdir(parents=True, exist_ok=True) + cfg_path.write_text(json.dumps(cfg, indent=2), encoding="utf-8") + with contextlib.suppress(NotImplementedError, OSError): + os.chmod(cfg_path, 0o600) + + +def build_exec_trust_context( + *, + policy: Any | None, + project_data: dict[str, Any] | None, +) -> ExecTrustContext: + """Assemble an :class:`ExecTrustContext` from org / project / user inputs. + + Args: + policy: The merged org :class:`~apm_cli.policy.schema.ApmPolicy` + (or ``None`` when no policy applies). + project_data: Raw project ``apm.yml`` data (or ``None``). + + The gate is enabled when ANY layer opts in: the project declares an + ``executables``/``allowExecutables`` block (even empty), or the org policy + carries a non-empty ``executables`` block, or a legacy ``bin_deploy`` deny. + """ + data = project_data or {} + project_allow, project_deny, _alias = parse_project_executables(data) + user_allow, user_deny = load_user_executables() + + org_deny_all = False + org_deny: frozenset[str] = frozenset() + org_require: frozenset[str] = frozenset() + org_recommend: frozenset[str] = frozenset() + org_enforce: frozenset[str] = frozenset() + org_bin_deny_all = False + org_bin_deny: frozenset[str] = frozenset() + org_signal = False + + if policy is not None: + execs = getattr(policy, "executables", None) + if execs is not None: + org_deny_all = bool(getattr(execs, "deny_all", False)) + org_deny = frozenset(getattr(execs, "deny", ()) or ()) + org_require = frozenset(getattr(execs, "require", ()) or ()) + org_recommend = frozenset(getattr(execs, "recommend", ()) or ()) + org_enforce = frozenset(getattr(execs, "enforce", ()) or ()) + org_signal = bool( + org_deny_all or org_deny or org_require or org_recommend or org_enforce + ) + bin_deploy = getattr(policy, "bin_deploy", None) + if bin_deploy is not None: + org_bin_deny_all = bool(getattr(bin_deploy, "deny_all", False)) + org_bin_deny = frozenset(getattr(bin_deploy, "deny", ()) or ()) + org_signal = org_signal or org_bin_deny_all or bool(org_bin_deny) + + gate_enabled = project_executables_gate_enabled(data) or org_signal + + return ExecTrustContext( + gate_enabled=gate_enabled, + org_deny_all=org_deny_all, + org_deny=org_deny, + org_require=org_require, + org_recommend=org_recommend, + org_enforce=org_enforce, + org_bin_deny_all=org_bin_deny_all, + org_bin_deny=org_bin_deny, + project_allow=project_allow, + project_deny=project_deny, + user_allow=user_allow, + user_deny=user_deny, + ) + + +def load_project_executables( + manifest_path: Path, +) -> tuple[dict[str, dict[str, bool]], dict[str, dict[str, bool]], bool]: + """Read the project ``executables`` block (and alias) from ``apm.yml``.""" + from ..utils.yaml_io import load_yaml + + if not manifest_path.is_file(): + return {}, {}, False + data = load_yaml(manifest_path) + if not isinstance(data, dict): + return {}, {}, False + return parse_project_executables(data) + + +def write_project_executables( + manifest_path: Path, + allow: dict[str, dict[str, bool]], + deny: dict[str, dict[str, bool]], +) -> None: + """Persist project ``executables: {allow, deny}`` back to ``apm.yml``. + + Migrates a legacy ``allowExecutables`` block into ``executables.allow`` on + write so a project converges on the unified noun. Empty ``allow``/``deny`` + sub-blocks are omitted; an empty ``executables: {}`` is still written when + the gate was already opted-in so the signal is not lost. + """ + from ..utils.yaml_io import dump_yaml, load_yaml + + data = load_yaml(manifest_path) + if not isinstance(data, dict): + return + + had_alias = data.pop("allowExecutables", None) is not None + block: dict[str, Any] = {} + if allow: + block["allow"] = allow + if deny: + block["deny"] = deny + + if block or had_alias or "executables" in data: + data["executables"] = block + dump_yaml(data, manifest_path) diff --git a/tests/fixtures/policy/golden/parsed-policies.json b/tests/fixtures/policy/golden/parsed-policies.json index 251482c70..54c57c108 100644 --- a/tests/fixtures/policy/golden/parsed-policies.json +++ b/tests/fixtures/policy/golden/parsed-policies.json @@ -29,6 +29,13 @@ "require_resolution": "project-wins" }, "enforcement": "warn", + "executables": { + "deny": [], + "deny_all": false, + "enforce": [], + "recommend": [], + "require": [] + }, "extends": null, "fetch_failure": "warn", "manifest": { @@ -96,6 +103,13 @@ "require_resolution": "project-wins" }, "enforcement": "block", + "executables": { + "deny": [], + "deny_all": false, + "enforce": [], + "recommend": [], + "require": [] + }, "extends": null, "fetch_failure": "warn", "manifest": { @@ -165,6 +179,13 @@ "require_resolution": "project-wins" }, "enforcement": "block", + "executables": { + "deny": [], + "deny_all": false, + "enforce": [], + "recommend": [], + "require": [] + }, "extends": null, "fetch_failure": "warn", "manifest": { @@ -232,6 +253,13 @@ "require_resolution": "project-wins" }, "enforcement": "warn", + "executables": { + "deny": [], + "deny_all": false, + "enforce": [], + "recommend": [], + "require": [] + }, "extends": "nonexistent-org/nonexistent-policy-repo", "fetch_failure": "warn", "manifest": { @@ -299,6 +327,13 @@ "require_resolution": "project-wins" }, "enforcement": "block", + "executables": { + "deny": [], + "deny_all": false, + "enforce": [], + "recommend": [], + "require": [] + }, "extends": "cycle-policy-a", "fetch_failure": "warn", "manifest": { @@ -368,6 +403,13 @@ "require_resolution": "project-wins" }, "enforcement": "warn", + "executables": { + "deny": [], + "deny_all": false, + "enforce": [], + "recommend": [], + "require": [] + }, "extends": "cycle-policy-b", "fetch_failure": "warn", "manifest": { @@ -437,6 +479,13 @@ "require_resolution": "project-wins" }, "enforcement": "warn", + "executables": { + "deny": [], + "deny_all": false, + "enforce": [], + "recommend": [], + "require": [] + }, "extends": "contoso-depth-5/policy", "fetch_failure": "warn", "manifest": { @@ -504,6 +553,13 @@ "require_resolution": "project-wins" }, "enforcement": "warn", + "executables": { + "deny": [], + "deny_all": false, + "enforce": [], + "recommend": [], + "require": [] + }, "extends": null, "fetch_failure": "warn", "manifest": { @@ -571,6 +627,13 @@ "require_resolution": "project-wins" }, "enforcement": "block", + "executables": { + "deny": [], + "deny_all": false, + "enforce": [], + "recommend": [], + "require": [] + }, "extends": null, "fetch_failure": "warn", "manifest": { @@ -638,6 +701,13 @@ "require_resolution": "project-wins" }, "enforcement": "block", + "executables": { + "deny": [], + "deny_all": false, + "enforce": [], + "recommend": [], + "require": [] + }, "extends": null, "fetch_failure": "warn", "manifest": { @@ -705,6 +775,13 @@ "require_resolution": "project-wins" }, "enforcement": "block", + "executables": { + "deny": [], + "deny_all": false, + "enforce": [], + "recommend": [], + "require": [] + }, "extends": null, "fetch_failure": "warn", "manifest": { @@ -780,6 +857,13 @@ "require_resolution": "project-wins" }, "enforcement": "off", + "executables": { + "deny": [], + "deny_all": false, + "enforce": [], + "recommend": [], + "require": [] + }, "extends": null, "fetch_failure": "warn", "manifest": { @@ -847,6 +931,13 @@ "require_resolution": "project-wins" }, "enforcement": "warn", + "executables": { + "deny": [], + "deny_all": false, + "enforce": [], + "recommend": [], + "require": [] + }, "extends": null, "fetch_failure": "warn", "manifest": { @@ -914,6 +1005,13 @@ "require_resolution": "project-wins" }, "enforcement": "block", + "executables": { + "deny": [], + "deny_all": false, + "enforce": [], + "recommend": [], + "require": [] + }, "extends": null, "fetch_failure": "warn", "manifest": { @@ -983,6 +1081,13 @@ "require_resolution": "project-wins" }, "enforcement": "block", + "executables": { + "deny": [], + "deny_all": false, + "enforce": [], + "recommend": [], + "require": [] + }, "extends": null, "fetch_failure": "warn", "manifest": { @@ -1052,6 +1157,13 @@ "require_resolution": "project-wins" }, "enforcement": "block", + "executables": { + "deny": [], + "deny_all": false, + "enforce": [], + "recommend": [], + "require": [] + }, "extends": null, "fetch_failure": "warn", "manifest": { @@ -1121,6 +1233,13 @@ "require_resolution": "project-wins" }, "enforcement": "block", + "executables": { + "deny": [], + "deny_all": false, + "enforce": [], + "recommend": [], + "require": [] + }, "extends": null, "fetch_failure": "warn", "manifest": { @@ -1188,6 +1307,13 @@ "require_resolution": "project-wins" }, "enforcement": "warn", + "executables": { + "deny": [], + "deny_all": false, + "enforce": [], + "recommend": [], + "require": [] + }, "extends": null, "fetch_failure": "warn", "manifest": { @@ -1264,6 +1390,13 @@ "require_resolution": "policy-wins" }, "enforcement": "block", + "executables": { + "deny": [], + "deny_all": false, + "enforce": [], + "recommend": [], + "require": [] + }, "extends": null, "fetch_failure": "warn", "manifest": { @@ -1331,6 +1464,13 @@ "require_resolution": "project-wins" }, "enforcement": "warn", + "executables": { + "deny": [], + "deny_all": false, + "enforce": [], + "recommend": [], + "require": [] + }, "extends": null, "fetch_failure": "warn", "manifest": { @@ -1410,6 +1550,13 @@ "require_resolution": "project-wins" }, "enforcement": "warn", + "executables": { + "deny": [], + "deny_all": false, + "enforce": [], + "recommend": [], + "require": [] + }, "extends": null, "fetch_failure": "warn", "manifest": { @@ -1495,6 +1642,13 @@ "require_resolution": "project-wins" }, "enforcement": "warn", + "executables": { + "deny": [], + "deny_all": false, + "enforce": [], + "recommend": [], + "require": [] + }, "extends": "org", "fetch_failure": "warn", "manifest": { diff --git a/tests/integration/test_executables_gate_integration.py b/tests/integration/test_executables_gate_integration.py index e8bc2d21e..2336a0c21 100644 --- a/tests/integration/test_executables_gate_integration.py +++ b/tests/integration/test_executables_gate_integration.py @@ -14,15 +14,19 @@ import pytest +from apm_cli.deps.lockfile import LockedDependency, LockFile from apm_cli.install.exec_gate import ( check_executable_approval, log_bin_status, resolve_package_key, ) +from apm_cli.install.phases.integrate import _run_executable_approval_prompt +from apm_cli.install.phases.lockfile import LockfileBuilder from apm_cli.security.executables import ( ALL_EXEC_TYPES, ENFORCED_EXEC_TYPES, EXEC_TYPE_BIN, + EXEC_TYPE_CANVAS, EXEC_TYPE_HOOKS, EXEC_TYPE_MCP, ExecutableDeclaration, @@ -131,9 +135,9 @@ def test_scan_mcp_from_apm_yml(self, tmp_path: Path) -> None: decl = scan_package_executables(tmp_path, "mcp-pkg", "1.0") assert decl.mcp_count == 2 - # MCP is not in enforced types - assert decl.has_executables is False - assert EXEC_TYPE_MCP not in decl.exec_types + # MCP is an enforced exec type (#1865 expanded the gate to MCP+canvas). + assert decl.has_executables is True + assert EXEC_TYPE_MCP in decl.exec_types def test_scan_package_key_format(self, tmp_path: Path) -> None: decl = scan_package_executables(tmp_path, "owner/repo", "v2.1.0") @@ -157,6 +161,39 @@ def test_symlinks_excluded_from_hooks(self, tmp_path: Path) -> None: assert decl.hook_details == ["real.json"] +class TestLockfileExecStatusIntegration: + """Integration: install-phase trust state is serialized into the lockfile.""" + + def test_attach_exec_status_serializes_to_lockfile_yaml(self) -> None: + lockfile = LockFile() + lockfile.add_dependency(LockedDependency(repo_url="owner/repo")) + ctx = SimpleNamespace(package_exec_status={"owner/repo": "gated_pending_approval"}) + + LockfileBuilder(ctx)._attach_exec_status(lockfile) + + assert lockfile.dependencies["owner/repo"].exec_status == "gated_pending_approval" + assert "exec_status: gated_pending_approval" in lockfile.to_yaml() + + +class TestNonInteractiveExecutablePromptIntegration: + """Integration: CI installs park executables instead of aborting.""" + + def test_noninteractive_blocked_executables_warn_without_exit(self, monkeypatch) -> None: + decl = ExecutableDeclaration( + package_key="owner/repo#1.0", package_name="owner/repo", hook_count=1 + ) + logger = MagicMock() + ctx = SimpleNamespace(blocked_executables=[decl], logger=logger) + + monkeypatch.setenv("APM_NON_INTERACTIVE", "1") + + _run_executable_approval_prompt(ctx) + + logger.warning.assert_called_once() + logger.info.assert_called_once() + assert "apm policy explain owner/repo" in logger.info.call_args.args[0] + + # ------------------------------------------------------------------- # Approval checking integration # ------------------------------------------------------------------- @@ -307,33 +344,35 @@ def _make_pkg_info(self, tmp_path: Path, name: str, version: str) -> MagicMock: def test_local_always_approved(self, tmp_path: Path) -> None: pkg_info = self._make_pkg_info(tmp_path, "_local", "") - hooks_ok, bin_ok = check_executable_approval("_local", pkg_info, {"deny-all": {}}) + hooks_ok, bin_ok, _mcp_ok, _canvas_ok = check_executable_approval( + "_local", pkg_info, {"deny-all": {}} + ) assert hooks_ok is True assert bin_ok is True def test_none_allow_executables_means_all_approved(self, tmp_path: Path) -> None: pkg_info = self._make_pkg_info(tmp_path, "any-pkg", "1.0") - hooks_ok, bin_ok = check_executable_approval("any-pkg", pkg_info, None) + hooks_ok, bin_ok, _mcp_ok, _canvas_ok = check_executable_approval("any-pkg", pkg_info, None) assert hooks_ok is True assert bin_ok is True def test_empty_allow_executables_blocks_all(self, tmp_path: Path) -> None: pkg_info = self._make_pkg_info(tmp_path, "pkg", "1.0") - hooks_ok, bin_ok = check_executable_approval("pkg", pkg_info, {}) + hooks_ok, bin_ok, _mcp_ok, _canvas_ok = check_executable_approval("pkg", pkg_info, {}) assert hooks_ok is False assert bin_ok is False def test_approved_package_passes(self, tmp_path: Path) -> None: pkg_info = self._make_pkg_info(tmp_path, "pkg", "1.0") allow = {"pkg#1.0": {"hooks": True, "bin": True}} - hooks_ok, bin_ok = check_executable_approval("pkg", pkg_info, allow) + hooks_ok, bin_ok, _mcp_ok, _canvas_ok = check_executable_approval("pkg", pkg_info, allow) assert hooks_ok is True assert bin_ok is True def test_partial_approval(self, tmp_path: Path) -> None: pkg_info = self._make_pkg_info(tmp_path, "pkg", "1.0") allow = {"pkg#1.0": {"hooks": True, "bin": False}} - hooks_ok, bin_ok = check_executable_approval("pkg", pkg_info, allow) + hooks_ok, bin_ok, _mcp_ok, _canvas_ok = check_executable_approval("pkg", pkg_info, allow) assert hooks_ok is True assert bin_ok is False @@ -344,7 +383,7 @@ def test_dep_ref_key_takes_priority(self, tmp_path: Path) -> None: pkg_info.dependency_ref = dep_ref allow = {"github:owner/repo#v1.0": {"hooks": True, "bin": True}} - hooks_ok, bin_ok = check_executable_approval("pkg", pkg_info, allow) + hooks_ok, bin_ok, _mcp_ok, _canvas_ok = check_executable_approval("pkg", pkg_info, allow) assert hooks_ok is True assert bin_ok is True @@ -356,7 +395,7 @@ def test_fallback_to_name_version_key(self, tmp_path: Path) -> None: # Approved under name#version, not dep-ref allow = {"pkg#1.0": {"hooks": True, "bin": True}} - hooks_ok, bin_ok = check_executable_approval("pkg", pkg_info, allow) + hooks_ok, bin_ok, _mcp_ok, _canvas_ok = check_executable_approval("pkg", pkg_info, allow) assert hooks_ok is True assert bin_ok is True @@ -507,8 +546,10 @@ def test_enforced_types_subset_of_all(self) -> None: for t in ENFORCED_EXEC_TYPES: assert t in ALL_EXEC_TYPES - def test_mcp_not_in_enforced(self) -> None: - assert EXEC_TYPE_MCP not in ENFORCED_EXEC_TYPES + def test_mcp_and_canvas_in_enforced(self) -> None: + # #1865 expanded the executable gate to cover MCP and canvas. + assert EXEC_TYPE_MCP in ENFORCED_EXEC_TYPES + assert EXEC_TYPE_CANVAS in ENFORCED_EXEC_TYPES def test_hooks_and_bin_in_enforced(self) -> None: assert EXEC_TYPE_HOOKS in ENFORCED_EXEC_TYPES @@ -685,12 +726,12 @@ def test_approve_specific_package(self, tmp_path: Path, monkeypatch) -> None: assert "Approved" in result.output assert "risky-pkg" in result.output - # Verify manifest was updated + # Verify manifest was updated (unified executables.allow noun) from apm_cli.utils.yaml_io import load_yaml data = load_yaml(project / "apm.yml") - assert "allowExecutables" in data - assert "risky-pkg#2.0.0" in data["allowExecutables"] + assert "executables" in data + assert "risky-pkg#2.0.0" in data["executables"]["allow"] def test_approve_all(self, tmp_path: Path, monkeypatch) -> None: from click.testing import CliRunner @@ -709,7 +750,7 @@ def test_approve_all(self, tmp_path: Path, monkeypatch) -> None: from apm_cli.utils.yaml_io import load_yaml data = load_yaml(project / "apm.yml") - allow = data["allowExecutables"] + allow = data["executables"]["allow"] assert "risky-pkg#2.0.0" in allow assert "tool-pkg#1.0.0" in allow @@ -773,14 +814,17 @@ def test_deny_package(self, tmp_path: Path, monkeypatch) -> None: result = runner.invoke(deny_cmd, ["risky-pkg"]) assert result.exit_code == 0 - assert "Revoked" in result.output + assert "Denied" in result.output from apm_cli.utils.yaml_io import load_yaml data = load_yaml(manifest) - assert "risky-pkg#2.0.0" not in data.get("allowExecutables", {}) - # tool-pkg should still be there - assert "tool-pkg#1.0.0" in data["allowExecutables"] + # deny migrates the legacy alias onto the unified noun + deny = data["executables"]["deny"] + assert "risky-pkg#2.0.0" in deny + # tool-pkg should remain allowed, not denied + assert "tool-pkg#1.0.0" in data["executables"]["allow"] + assert "tool-pkg#1.0.0" not in deny def test_deny_nonexistent_package(self, tmp_path: Path, monkeypatch) -> None: from click.testing import CliRunner @@ -796,7 +840,13 @@ def test_deny_nonexistent_package(self, tmp_path: Path, monkeypatch) -> None: result = runner.invoke(deny_cmd, ["missing"]) assert result.exit_code == 0 - assert "not found" in result.output + # deny may target a not-yet-installed package (a pre-emptive block) + assert "Denied missing" in result.output + + from apm_cli.utils.yaml_io import load_yaml + + data = load_yaml(manifest) + assert "missing" in data["executables"]["deny"] def test_approve_no_manifest_errors(self, tmp_path: Path, monkeypatch) -> None: from click.testing import CliRunner diff --git a/tests/integration/test_wave7_policy_registry_coverage.py b/tests/integration/test_wave7_policy_registry_coverage.py index 92409ec6d..5af456fc5 100644 --- a/tests/integration/test_wave7_policy_registry_coverage.py +++ b/tests/integration/test_wave7_policy_registry_coverage.py @@ -254,12 +254,26 @@ def test_required_deployed(self): result = _check_required_packages_deployed([dep], lf, policy) assert result.passed - def test_required_not_deployed(self): + def test_required_present_but_parked(self): + # Gap B (issue #1873): a required package PRESENT in the lockfile but + # with no deployed_files (executables gated pending approval) is a + # healthy present-but-parked state -- presence passes; trust is audited + # separately by required-executable-untrusted. policy = DependencyPolicy(require=("acme/pkg",)) dep = _dep("acme/pkg") locked = _locked("acme/pkg", deployed_files=[]) # no deployed files lf = _lock(locked) result = _check_required_packages_deployed([dep], lf, policy) + assert result.passed + assert "present in lockfile" in result.message + + def test_required_absent_from_lockfile(self): + # Presence check fails only when the required package is missing from + # the lockfile entirely. + policy = DependencyPolicy(require=("acme/pkg",)) + dep = _dep("acme/pkg") + lf = _lock() # empty lockfile -- acme/pkg absent + result = _check_required_packages_deployed([dep], lf, policy) assert not result.passed assert "1 required package" in result.message diff --git a/tests/unit/commands/test_approve_deny.py b/tests/unit/commands/test_approve_deny.py index b54508d54..52c66a66e 100644 --- a/tests/unit/commands/test_approve_deny.py +++ b/tests/unit/commands/test_approve_deny.py @@ -1,9 +1,8 @@ -"""Unit tests for ``apm_cli.commands.approve`` (apm approve / apm deny). +"""Unit tests for ``apm_cli.commands.approve`` (apm approve / deny / explain). -Covers: -- ``approve_cmd``: no args error, --pending flag, --all flag, named packages -- ``deny_cmd``: exact match, prefix match, not found -- ``_find_matching_key``: exact and prefix matching +Issue #1873 vocabulary unification: ``apm approve`` writes to the project +``apm.yml`` ``executables.allow`` block by DEFAULT (committed, admin UX), and +to ``~/.apm/config.json`` only with ``--user`` (personal, lowest authority). """ from __future__ import annotations @@ -19,6 +18,8 @@ approve_cmd, deny_cmd, ) +from apm_cli.commands.policy import policy as policy_group +from apm_cli.policy.schema import ApmPolicy, ExecutablesPolicy # --------------------------------------------------------------------------- # Helpers @@ -35,12 +36,6 @@ def _write_manifest(tmpdir: str, extra: dict | None = None) -> Path: return manifest -def _write_user_approvals(approvals_path: Path, data: dict) -> None: - """Write approval data to the given (mocked) user approvals file.""" - approvals_path.parent.mkdir(parents=True, exist_ok=True) - approvals_path.write_text(yaml.dump(data)) - - def _create_pkg_with_hooks(apm_modules: Path, name: str) -> None: """Create a package directory with a hook file.""" pkg_dir = apm_modules / name @@ -59,14 +54,23 @@ def _create_pkg_with_bin(apm_modules: Path, name: str) -> None: (pkg_dir / "apm.yml").write_text(yaml.dump({"name": name, "version": "2.0"})) +def _isolated_config(tmp_path: Path): + """Patch the user-config + legacy-approvals seams onto tmp_path.""" + cfg = tmp_path / "config.json" + legacy = tmp_path / "approvals.yml" + return ( + patch("apm_cli.security.executables._user_config_file", lambda: cfg), + patch("apm_cli.security.executables._legacy_approvals_path", lambda: legacy), + cfg, + ) + + # --------------------------------------------------------------------------- # _find_matching_key # --------------------------------------------------------------------------- class TestFindMatchingKey: - """Tests for _find_matching_key prefix/exact matching.""" - def test_exact_match(self) -> None: allow = {"owner/repo#v1.0": {"hooks": True}} assert _find_matching_key(allow, "owner/repo#v1.0") == "owner/repo#v1.0" @@ -89,8 +93,6 @@ def test_empty_dict(self) -> None: class TestApproveCmd: - """Tests for the apm approve CLI command.""" - def test_no_manifest_exits_1(self) -> None: runner = CliRunner() with runner.isolated_filesystem(): @@ -117,71 +119,109 @@ def test_pending_with_unapproved_packages(self) -> None: runner = CliRunner() with runner.isolated_filesystem(): _write_manifest(".") - apm_modules = Path("apm_modules") - _create_pkg_with_hooks(apm_modules, "hook-pkg") - + _create_pkg_with_hooks(Path("apm_modules"), "hook-pkg") result = runner.invoke(approve_cmd, ["--pending"]) assert result.exit_code == 0 assert "hook-pkg" in result.output - def test_approve_all_writes_user_file(self, tmp_path: Path) -> None: - """apm approve --all must write to ~/.apm/approvals.yml, not project apm.yml.""" - approvals_file = tmp_path / ".apm" / "approvals.yml" + def test_approve_all_writes_project_manifest(self) -> None: + """apm approve --all writes to the project apm.yml executables block.""" runner = CliRunner() with runner.isolated_filesystem(): _write_manifest(".") - apm_modules = Path("apm_modules") - _create_pkg_with_hooks(apm_modules, "hook-pkg") - _create_pkg_with_bin(apm_modules, "bin-pkg") - - with patch( - "apm_cli.security.executables.get_user_approvals_path", - return_value=approvals_file, - ): - result = runner.invoke(approve_cmd, ["--all"]) + _create_pkg_with_hooks(Path("apm_modules"), "hook-pkg") + _create_pkg_with_bin(Path("apm_modules"), "bin-pkg") + result = runner.invoke(approve_cmd, ["--all"]) assert result.exit_code == 0 assert "Approved" in result.output - # Verify written to user-local file, NOT project apm.yml from apm_cli.utils.yaml_io import load_yaml project_data = load_yaml(Path("apm.yml")) - assert "allowExecutables" not in project_data - - assert approvals_file.is_file() - user_data = load_yaml(approvals_file) - assert isinstance(user_data, dict) - assert len(user_data) > 0 + assert "executables" in project_data + assert project_data["executables"]["allow"] - def test_approve_specific_package_writes_user_file(self, tmp_path: Path) -> None: - approvals_file = tmp_path / ".apm" / "approvals.yml" + def test_approve_specific_package_writes_project(self) -> None: runner = CliRunner() with runner.isolated_filesystem(): _write_manifest(".") - apm_modules = Path("apm_modules") - _create_pkg_with_hooks(apm_modules, "hook-pkg") + _create_pkg_with_hooks(Path("apm_modules"), "hook-pkg") - with patch( - "apm_cli.security.executables.get_user_approvals_path", - return_value=approvals_file, - ): - result = runner.invoke(approve_cmd, ["hook-pkg"]) + result = runner.invoke(approve_cmd, ["hook-pkg"]) + assert result.exit_code == 0 + assert "Approved" in result.output + + from apm_cli.utils.yaml_io import load_yaml + + data = load_yaml(Path("apm.yml")) + assert data["executables"]["allow"] + + def test_approve_user_scope_writes_config(self, tmp_path: Path) -> None: + p_cfg, p_legacy, cfg = _isolated_config(tmp_path) + runner = CliRunner() + with runner.isolated_filesystem(), p_cfg, p_legacy: + _write_manifest(".") + _create_pkg_with_hooks(Path("apm_modules"), "hook-pkg") + result = runner.invoke(approve_cmd, ["--user", "hook-pkg"]) assert result.exit_code == 0 assert "Approved" in result.output - assert approvals_file.is_file() + assert cfg.is_file() + + import json + + stored = json.loads(cfg.read_text()) + assert stored["executables"]["allow"] + # The project manifest is untouched under --user. + from apm_cli.utils.yaml_io import load_yaml + + assert "executables" not in load_yaml(Path("apm.yml")) def test_approve_unknown_package(self) -> None: runner = CliRunner() with runner.isolated_filesystem(): _write_manifest(".") Path("apm_modules").mkdir() - result = runner.invoke(approve_cmd, ["nonexistent"]) assert result.exit_code == 0 assert "not found" in result.output + def test_approve_recommended_bulk_accepts_org_set(self) -> None: + runner = CliRunner() + with runner.isolated_filesystem(): + _write_manifest(".") + _create_pkg_with_hooks(Path("apm_modules"), "hook-pkg") + policy = ApmPolicy(executables=ExecutablesPolicy(recommend=("hook-pkg",))) + + with patch("apm_cli.commands.approve._load_org_policy", return_value=policy): + result = runner.invoke(approve_cmd, ["--recommended"]) + + assert result.exit_code == 0 + assert "Approved" in result.output + from apm_cli.utils.yaml_io import load_yaml + + assert load_yaml(Path("apm.yml"))["executables"]["allow"] + + def test_approve_recommended_empty_set(self) -> None: + runner = CliRunner() + with runner.isolated_filesystem(): + _write_manifest(".") + with patch("apm_cli.commands.approve._load_org_policy", return_value=ApmPolicy()): + result = runner.invoke(approve_cmd, ["--recommended"]) + assert result.exit_code == 0 + assert "No org-recommended" in result.output + + def test_approve_list_shows_decisions(self) -> None: + runner = CliRunner() + with runner.isolated_filesystem(): + _write_manifest(".", {"executables": {"allow": {}}}) + _create_pkg_with_hooks(Path("apm_modules"), "hook-pkg") + with patch("apm_cli.commands.approve._load_org_policy", return_value=ApmPolicy()): + result = runner.invoke(approve_cmd, ["--list"]) + assert result.exit_code == 0 + assert "hook-pkg" in result.output + # --------------------------------------------------------------------------- # deny_cmd @@ -189,61 +229,82 @@ def test_approve_unknown_package(self) -> None: class TestDenyCmd: - """Tests for the apm deny CLI command.""" - - def test_deny_existing_entry(self, tmp_path: Path) -> None: - """apm deny must remove from user file, not project apm.yml.""" - approvals_file = tmp_path / ".apm" / "approvals.yml" - _write_user_approvals(approvals_file, {"pkg#1.0": {"hooks": True}}) - + def test_deny_writes_project_deny(self) -> None: runner = CliRunner() with runner.isolated_filesystem(): _write_manifest(".") - - with patch( - "apm_cli.security.executables.get_user_approvals_path", - return_value=approvals_file, - ): - result = runner.invoke(deny_cmd, ["pkg#1.0"]) - + _create_pkg_with_hooks(Path("apm_modules"), "hook-pkg") + result = runner.invoke(deny_cmd, ["hook-pkg"]) assert result.exit_code == 0 - assert "Revoked" in result.output + assert "Denied" in result.output from apm_cli.utils.yaml_io import load_yaml - user_data = load_yaml(approvals_file) or {} - assert "pkg#1.0" not in user_data - - def test_deny_prefix_match(self, tmp_path: Path) -> None: - approvals_file = tmp_path / ".apm" / "approvals.yml" - _write_user_approvals(approvals_file, {"owner/repo#v1.0": {"hooks": True}}) + data = load_yaml(Path("apm.yml")) + assert data["executables"]["deny"] + def test_deny_uninstalled_package(self) -> None: runner = CliRunner() with runner.isolated_filesystem(): _write_manifest(".") + Path("apm_modules").mkdir() + result = runner.invoke(deny_cmd, ["owner/repo"]) + assert result.exit_code == 0 + assert "Denied" in result.output - with patch( - "apm_cli.security.executables.get_user_approvals_path", - return_value=approvals_file, - ): - result = runner.invoke(deny_cmd, ["owner/repo"]) - + def test_deny_user_scope_writes_config(self, tmp_path: Path) -> None: + p_cfg, p_legacy, cfg = _isolated_config(tmp_path) + runner = CliRunner() + with runner.isolated_filesystem(), p_cfg, p_legacy: + _write_manifest(".") + Path("apm_modules").mkdir() + result = runner.invoke(deny_cmd, ["--user", "owner/repo"]) assert result.exit_code == 0 - assert "Revoked" in result.output + import json + + stored = json.loads(cfg.read_text()) + assert stored["executables"]["deny"] + - def test_deny_not_found(self, tmp_path: Path) -> None: - approvals_file = tmp_path / ".apm" / "approvals.yml" - _write_user_approvals(approvals_file, {}) +# --------------------------------------------------------------------------- +# apm policy explain +# --------------------------------------------------------------------------- + +class TestExplainCmd: + def test_explain_unknown_package(self) -> None: runner = CliRunner() with runner.isolated_filesystem(): _write_manifest(".") + Path("apm_modules").mkdir() + with patch("apm_cli.commands.approve._load_org_policy", return_value=ApmPolicy()): + result = runner.invoke(policy_group, ["explain", "nonexistent"]) + assert result.exit_code == 0 + assert "not found" in result.output - with patch( - "apm_cli.security.executables.get_user_approvals_path", - return_value=approvals_file, - ): - result = runner.invoke(deny_cmd, ["nonexistent"]) + def test_explain_blocked_package_shows_layer_and_remedy(self) -> None: + runner = CliRunner() + with runner.isolated_filesystem(): + # Gate enabled (executables block present) but nothing approved. + _write_manifest(".", {"executables": {"allow": {}}}) + _create_pkg_with_hooks(Path("apm_modules"), "hook-pkg") + with patch("apm_cli.commands.approve._load_org_policy", return_value=ApmPolicy()): + result = runner.invoke(policy_group, ["explain", "hook-pkg"]) + assert result.exit_code == 0 + assert "blocked" in result.output + assert "default-deny" in result.output + assert "apm approve" in result.output + def test_explain_allowed_via_project(self) -> None: + runner = CliRunner() + with runner.isolated_filesystem(): + _create_pkg_with_hooks(Path("apm_modules"), "hook-pkg") + _write_manifest( + ".", + {"executables": {"allow": {"hook-pkg": {"hooks": True}}}}, + ) + with patch("apm_cli.commands.approve._load_org_policy", return_value=ApmPolicy()): + result = runner.invoke(policy_group, ["explain", "hook-pkg"]) assert result.exit_code == 0 - assert "not found" in result.output + assert "allowed" in result.output + assert "project-allow" in result.output diff --git a/tests/unit/commands/test_marketplace_doctor.py b/tests/unit/commands/test_marketplace_doctor.py index c6aeb54ad..91aeffb7d 100644 --- a/tests/unit/commands/test_marketplace_doctor.py +++ b/tests/unit/commands/test_marketplace_doctor.py @@ -782,3 +782,67 @@ def test_apm_yml_marketplace_error_logged(self, mock_run, runner, tmp_path, monk result = runner.invoke(cli, ["doctor"]) # Either passes or fails gracefully assert result.exit_code in (0, 1, 2) + + +# --------------------------------------------------------------------------- +# Executable-trust drift check (apm doctor Check 8) +# --------------------------------------------------------------------------- + + +class TestExecutableTrustDriftCheck: + """The fleet-level executable-trust drift probe in ``apm doctor``.""" + + def _make_hook_pkg(self, tmp_path, name: str = "hook-pkg") -> None: + import yaml + + hooks = tmp_path / "apm_modules" / name / ".apm" / "hooks" + hooks.mkdir(parents=True) + (hooks / "pre-tool-use.json").write_text("{}") + (tmp_path / "apm_modules" / name / "apm.yml").write_text( + yaml.dump({"name": name, "version": "1.0"}) + ) + + def test_no_manifest_returns_none(self, tmp_path) -> None: + from apm_cli.commands.marketplace.doctor import _executable_trust_drift_check + + assert _executable_trust_drift_check(tmp_path) is None + + def test_gate_disabled_is_informational_pass(self, tmp_path) -> None: + from apm_cli.commands.marketplace.doctor import _executable_trust_drift_check + + (tmp_path / "apm.yml").write_text("name: t\nversion: 0.0.1\n") + check = _executable_trust_drift_check(tmp_path) + assert check is not None + assert check.passed is True + assert check.informational is True + assert "disabled" in check.detail.lower() + + def test_no_conflict_passes(self, tmp_path) -> None: + from apm_cli.commands.marketplace.doctor import _executable_trust_drift_check + from apm_cli.policy.schema import ApmPolicy + + (tmp_path / "apm.yml").write_text( + "name: t\nversion: 0.0.1\nexecutables:\n allow:\n hook-pkg:\n hooks: true\n" + ) + self._make_hook_pkg(tmp_path) + with patch("apm_cli.commands.approve._load_org_policy", return_value=ApmPolicy()): + check = _executable_trust_drift_check(tmp_path) + assert check is not None + assert check.passed is True + assert check.informational is True + + def test_org_deny_shadowing_project_allow_flags_drift(self, tmp_path) -> None: + from apm_cli.commands.marketplace.doctor import _executable_trust_drift_check + from apm_cli.policy.schema import ApmPolicy, ExecutablesPolicy + + (tmp_path / "apm.yml").write_text( + "name: t\nversion: 0.0.1\nexecutables:\n allow:\n hook-pkg:\n hooks: true\n" + ) + self._make_hook_pkg(tmp_path) + org = ApmPolicy(executables=ExecutablesPolicy(deny=["hook-pkg"])) + with patch("apm_cli.commands.approve._load_org_policy", return_value=org): + check = _executable_trust_drift_check(tmp_path) + assert check is not None + assert check.passed is False + assert check.informational is True + assert "apm policy explain hook-pkg" in check.detail diff --git a/tests/unit/policy/test_inheritance.py b/tests/unit/policy/test_inheritance.py index bcf08842f..b5ed262e2 100644 --- a/tests/unit/policy/test_inheritance.py +++ b/tests/unit/policy/test_inheritance.py @@ -22,6 +22,7 @@ CompilationStrategyPolicy, CompilationTargetPolicy, DependencyPolicy, + ExecutablesPolicy, ManifestPolicy, McpPolicy, McpTransportPolicy, @@ -834,6 +835,44 @@ def test_deny_union_merged(self): self.assertEqual(set(result.bin_deploy.deny), {"a/b", "c/d"}) +class TestExecutablesMerge(unittest.TestCase): + """executables tightens: deny_all OR-sticks; lists union (issue #1873).""" + + def test_deny_all_sticks_when_child_silent(self): + parent = ApmPolicy(executables=ExecutablesPolicy(deny_all=True)) + result = merge_policies(parent, ApmPolicy()) + self.assertTrue(result.executables.deny_all) + + def test_deny_all_child_cannot_relax(self): + parent = ApmPolicy(executables=ExecutablesPolicy(deny_all=True)) + child = ApmPolicy(executables=ExecutablesPolicy(deny_all=False)) + result = merge_policies(parent, child) + self.assertTrue(result.executables.deny_all) + + def test_deny_union_merged(self): + parent = ApmPolicy(executables=ExecutablesPolicy(deny=("a/b",))) + child = ApmPolicy(executables=ExecutablesPolicy(deny=("c/d",))) + result = merge_policies(parent, child) + self.assertEqual(set(result.executables.deny), {"a/b", "c/d"}) + + def test_require_union_merged(self): + parent = ApmPolicy(executables=ExecutablesPolicy(require=("org/hook",))) + child = ApmPolicy(executables=ExecutablesPolicy(require=("org/mcp",))) + result = merge_policies(parent, child) + self.assertEqual(set(result.executables.require), {"org/hook", "org/mcp"}) + + def test_recommend_union_merged(self): + parent = ApmPolicy(executables=ExecutablesPolicy(recommend=("org/a",))) + child = ApmPolicy(executables=ExecutablesPolicy(recommend=("org/b",))) + result = merge_policies(parent, child) + self.assertEqual(set(result.executables.recommend), {"org/a", "org/b"}) + + def test_enforce_union_merged(self): + parent = ApmPolicy(executables=ExecutablesPolicy(enforce=("org/x",))) + result = merge_policies(parent, ApmPolicy()) + self.assertEqual(result.executables.enforce, ("org/x",)) + + class TestMergeUnmanagedExclude(unittest.TestCase): """The unmanaged-files ``exclude`` list is union-merged across a chain.""" @@ -896,6 +935,7 @@ def _non_default_parent() -> ApmPolicy: registry_source=RegistrySourcePolicy(require=("corp",)), security=SecurityPolicy(audit=AuditPolicy(on_install="block")), bin_deploy=BinDeployPolicy(deny_all=True), + executables=ExecutablesPolicy(deny_all=True), ) def test_sample_covers_every_mergeable_field(self): diff --git a/tests/unit/policy/test_parser.py b/tests/unit/policy/test_parser.py index e2bcdc066..78ff2bd89 100644 --- a/tests/unit/policy/test_parser.py +++ b/tests/unit/policy/test_parser.py @@ -123,6 +123,57 @@ def test_non_dict_input(self): self.assertEqual(len(errors), 1) self.assertIn("mapping", errors[0]) + def test_executables_block_valid(self): + errors, warnings = validate_policy( # noqa: RUF059 + { + "executables": { + "deny_all": False, + "deny": ["bad/pkg"], + "require": ["org/hook"], + "recommend": ["org/vetted"], + "enforce": ["org/mandated"], + } + } + ) + self.assertEqual(errors, []) + + def test_executables_deny_all_must_be_bool(self): + errors, _ = validate_policy({"executables": {"deny_all": "yes"}}) + self.assertTrue(any("deny_all" in e for e in errors)) + + def test_executables_deny_must_be_list(self): + errors, _ = validate_policy({"executables": {"deny": "bad/pkg"}}) + self.assertTrue(any("executables.deny" in e for e in errors)) + + def test_executables_must_be_mapping(self): + errors, _ = validate_policy({"executables": ["bad/pkg"]}) + self.assertTrue(any("executables must be a YAML mapping" in e for e in errors)) + + def test_bin_deploy_emits_deprecation_warning(self): + _, warnings = validate_policy({"bin_deploy": {"deny_all": True}}) + self.assertTrue(any("deprecated" in w and "bin_deploy" in w for w in warnings)) + + def test_build_executables_policy(self): + policy = _build_policy( + { + "executables": { + "deny_all": True, + "deny": ["bad/pkg"], + "require": ["org/hook"], + "recommend": ["org/vetted"], + } + } + ) + self.assertTrue(policy.executables.deny_all) + self.assertEqual(policy.executables.deny, ("bad/pkg",)) + self.assertEqual(policy.executables.require, ("org/hook",)) + self.assertEqual(policy.executables.recommend, ("org/vetted",)) + + def test_build_executables_default_empty(self): + policy = _build_policy({"name": "x"}) + self.assertFalse(policy.executables.deny_all) + self.assertEqual(policy.executables.deny, ()) + def test_multiple_errors(self): errors, warnings = validate_policy( # noqa: RUF059 { diff --git a/tests/unit/policy/test_policy_checks.py b/tests/unit/policy/test_policy_checks.py index b9a39a443..120b6e22c 100644 --- a/tests/unit/policy/test_policy_checks.py +++ b/tests/unit/policy/test_policy_checks.py @@ -21,6 +21,7 @@ _check_mcp_denylist, _check_mcp_self_defined, _check_mcp_transport, + _check_required_executable_untrusted, _check_required_manifest_fields, _check_required_package_version, _check_required_packages, @@ -38,6 +39,7 @@ CompilationStrategyPolicy, CompilationTargetPolicy, DependencyPolicy, + ExecutablesPolicy, ManifestPolicy, McpPolicy, McpTransportPolicy, @@ -209,22 +211,25 @@ def test_pass_deployed(self): result = _check_required_packages_deployed(deps, lock, policy) assert result.passed - def test_fail_not_deployed(self): + def test_pass_present_but_parked(self): + """Gap B (#1873): a package present in the lockfile with NO deployed + files (executables gated pending approval) is PRESENT and must pass -- + the old deployed_files test mis-fired on this healthy parked state.""" deps = _make_dep_refs(["org/pkg"]) lock = _make_lockfile([{"repo_url": "org/pkg", "deployed_files": []}]) policy = DependencyPolicy(require=["org/pkg"]) result = _check_required_packages_deployed(deps, lock, policy) - assert not result.passed - assert "org/pkg" in result.details[0] + assert result.passed - def test_fail_message_includes_no_policy_hint(self): - """The error message includes a --no-policy recovery hint so users in - the catch-22 know how to self-heal without reading the docs.""" + def test_fail_absent_from_lockfile(self): + """A required package in the manifest but absent from the lockfile is a + genuine presence failure and includes the --no-policy recovery hint.""" deps = _make_dep_refs(["org/pkg"]) - lock = _make_lockfile([{"repo_url": "org/pkg", "deployed_files": []}]) + lock = _make_lockfile([{"repo_url": "other/pkg", "deployed_files": ["x.md"]}]) policy = DependencyPolicy(require=["org/pkg"]) result = _check_required_packages_deployed(deps, lock, policy) assert not result.passed + assert "org/pkg" in result.details[0] assert "--no-policy" in result.message def test_skip_if_not_in_manifest(self): @@ -236,6 +241,51 @@ def test_skip_if_not_in_manifest(self): assert result.passed +# -- Check 4b: required-executable-untrusted ------------------------ + + +class TestRequiredExecutableUntrusted: + def test_pass_no_required_executables(self): + deps = _make_dep_refs(["org/pkg"]) + lock = _make_lockfile([{"repo_url": "org/pkg"}]) + result = _check_required_executable_untrusted(deps, lock, ExecutablesPolicy()) + assert result.passed + + def test_pass_required_executable_deployed(self): + deps = _make_dep_refs(["org/pkg"]) + lock = _make_lockfile([{"repo_url": "org/pkg", "exec_status": "deployed"}]) + result = _check_required_executable_untrusted( + deps, lock, ExecutablesPolicy(require=("org/pkg",)) + ) + assert result.passed + + def test_fail_required_executable_parked(self): + deps = _make_dep_refs(["org/pkg"]) + lock = _make_lockfile([{"repo_url": "org/pkg", "exec_status": "gated_pending_approval"}]) + result = _check_required_executable_untrusted( + deps, lock, ExecutablesPolicy(require=("org/pkg",)) + ) + assert not result.passed + assert "org/pkg" in result.details + assert "apm approve" in result.message + + def test_fail_required_executable_denied(self): + deps = _make_dep_refs(["org/pkg"]) + lock = _make_lockfile([{"repo_url": "org/pkg", "exec_status": "denied"}]) + result = _check_required_executable_untrusted( + deps, lock, ExecutablesPolicy(require=("org/pkg",)) + ) + assert not result.passed + + def test_skip_if_not_in_manifest(self): + deps = _make_dep_refs(["other/pkg"]) + lock = _make_lockfile([{"repo_url": "other/pkg", "exec_status": "deployed"}]) + result = _check_required_executable_untrusted( + deps, lock, ExecutablesPolicy(require=("org/missing",)) + ) + assert result.passed + + # -- Check 5: required-package-version ------------------------------ @@ -1096,7 +1146,7 @@ def test_file_reported_once_even_with_deny_conflict(self, tmp_path): # -- Integration: run_policy_checks --------------------------------- class TestRunPolicyChecks: def test_returns_all_18_checks(self, tmp_path): - """Full run should produce exactly 18 checks.""" + """Full run should produce exactly 20 checks.""" _write_apm_yml( tmp_path, { @@ -1120,7 +1170,7 @@ def test_returns_all_18_checks(self, tmp_path): policy = ApmPolicy() result = run_policy_checks(tmp_path, policy) - assert len(result.checks) == 19 + assert len(result.checks) == 20 # Default policy = all checks pass assert result.passed diff --git a/tests/unit/security/test_exec_vocab_unification.py b/tests/unit/security/test_exec_vocab_unification.py new file mode 100644 index 000000000..549b71e96 --- /dev/null +++ b/tests/unit/security/test_exec_vocab_unification.py @@ -0,0 +1,126 @@ +"""Tests for the unified executables vocabulary layer (issue #1873). + +Covers: +- ``parse_project_executables`` (new ``executables:{allow,deny}`` block + + the deprecated ``allowExecutables`` alias). +- The user-local consent store on ``~/.apm/config.json`` plus the one-time + migration that folds and DELETES the legacy ``~/.apm/approvals.yml``. +- ``build_exec_trust_context`` assembling org / project / user inputs. +""" + +from __future__ import annotations + +import apm_cli.security.executables as ex +from apm_cli.policy.schema import ApmPolicy, BinDeployPolicy, ExecutablesPolicy +from apm_cli.security.executables import ( + EXEC_TYPE_BIN, + EXEC_TYPE_HOOKS, + build_exec_trust_context, + load_user_executables, + parse_project_executables, + save_user_executables, +) + + +class TestParseProjectExecutables: + def test_absent_returns_empty_no_deprecation(self): + allow, deny, deprecated = parse_project_executables({}) + assert allow == {} + assert deny == {} + assert deprecated is False + + def test_new_block_allow_and_deny(self): + data = { + "executables": { + "allow": {"owner/repo": {"hooks": True}}, + "deny": {"bad/pkg": {"bin": True}}, + } + } + allow, deny, deprecated = parse_project_executables(data) + assert allow == {"owner/repo": {"hooks": True}} + assert deny == {"bad/pkg": {"bin": True}} + assert deprecated is False + + def test_allow_executables_alias_sets_deprecation_flag(self): + data = {"allowExecutables": {"owner/repo": {"hooks": True}}} + allow, _deny, deprecated = parse_project_executables(data) + assert allow == {"owner/repo": {"hooks": True}} + assert deprecated is True + + def test_new_block_wins_over_alias_on_conflict(self): + data = { + "allowExecutables": {"owner/repo": {"hooks": False}}, + "executables": {"allow": {"owner/repo": {"hooks": True}}}, + } + allow, _deny, deprecated = parse_project_executables(data) + assert allow["owner/repo"]["hooks"] is True + assert deprecated is True + + +class TestUserExecutablesStore: + def test_roundtrip_via_config(self, tmp_path, monkeypatch): + cfg = tmp_path / "config.json" + monkeypatch.setattr(ex, "_user_config_file", lambda: cfg) + monkeypatch.setattr(ex, "_legacy_approvals_path", lambda: tmp_path / "approvals.yml") + + save_user_executables({"owner/repo": {"hooks": True}}, {"bad/pkg": {"bin": True}}) + allow, deny = load_user_executables() + assert allow == {"owner/repo": {"hooks": True}} + assert deny == {"bad/pkg": {"bin": True}} + + def test_migrates_and_deletes_legacy_approvals_yml(self, tmp_path, monkeypatch): + cfg = tmp_path / "config.json" + legacy = tmp_path / "approvals.yml" + monkeypatch.setattr(ex, "_user_config_file", lambda: cfg) + monkeypatch.setattr(ex, "_legacy_approvals_path", lambda: legacy) + + from apm_cli.utils.yaml_io import dump_yaml + + dump_yaml({"owner/repo": {"hooks": True}}, legacy) + assert legacy.exists() + + allow, _deny = load_user_executables() + # Legacy approvals folded into the allow set. + assert allow.get("owner/repo", {}).get("hooks") is True + # net-new control-surface files = 0: the legacy file is removed. + assert not legacy.exists() + + +class TestBuildExecTrustContext: + def test_org_executables_block_enables_gate_fleetwide(self, tmp_path, monkeypatch): + monkeypatch.setattr(ex, "_user_config_file", lambda: tmp_path / "c.json") + monkeypatch.setattr(ex, "_legacy_approvals_path", lambda: tmp_path / "a.yml") + policy = ApmPolicy(executables=ExecutablesPolicy(deny=("bad/pkg",))) + ctx = build_exec_trust_context(policy=policy, project_data={}) + assert ctx.gate_enabled is True + assert "bad/pkg" in ctx.org_deny + + def test_legacy_bin_deploy_maps_to_bin_deny(self, tmp_path, monkeypatch): + monkeypatch.setattr(ex, "_user_config_file", lambda: tmp_path / "c.json") + monkeypatch.setattr(ex, "_legacy_approvals_path", lambda: tmp_path / "a.yml") + policy = ApmPolicy(bin_deploy=BinDeployPolicy(deny=("bad/pkg",))) + ctx = build_exec_trust_context(policy=policy, project_data={}) + assert "bad/pkg" in ctx.org_bin_deny + + def test_project_block_enables_gate(self, tmp_path, monkeypatch): + monkeypatch.setattr(ex, "_user_config_file", lambda: tmp_path / "c.json") + monkeypatch.setattr(ex, "_legacy_approvals_path", lambda: tmp_path / "a.yml") + data = {"executables": {"allow": {"owner/repo": {"hooks": True}}}} + ctx = build_exec_trust_context(policy=None, project_data=data) + assert ctx.gate_enabled is True + assert ctx.project_allow == {"owner/repo": {"hooks": True}} + + def test_no_signals_gate_disabled(self, tmp_path, monkeypatch): + monkeypatch.setattr(ex, "_user_config_file", lambda: tmp_path / "c.json") + monkeypatch.setattr(ex, "_legacy_approvals_path", lambda: tmp_path / "a.yml") + ctx = build_exec_trust_context(policy=None, project_data={}) + assert ctx.gate_enabled is False + + def test_user_consent_flows_into_context(self, tmp_path, monkeypatch): + monkeypatch.setattr(ex, "_user_config_file", lambda: tmp_path / "c.json") + monkeypatch.setattr(ex, "_legacy_approvals_path", lambda: tmp_path / "a.yml") + save_user_executables({"owner/repo": {EXEC_TYPE_HOOKS: True}}, {}) + data = {"executables": {"allow": {}}} + ctx = build_exec_trust_context(policy=None, project_data=data) + assert ctx.user_allow.get("owner/repo", {}).get(EXEC_TYPE_HOOKS) is True + assert EXEC_TYPE_BIN not in ctx.user_allow.get("owner/repo", {}) diff --git a/tests/unit/security/test_resolve_exec_decision.py b/tests/unit/security/test_resolve_exec_decision.py new file mode 100644 index 000000000..76a7f2307 --- /dev/null +++ b/tests/unit/security/test_resolve_exec_decision.py @@ -0,0 +1,269 @@ +"""Tests for the unified executable-trust resolver (issue #1873). + +``resolve_exec_decision`` implements the deny-wins, first-match-wins +precedence ladder shared by the install gate and the policy audit. The +8 rows of the precedence table in #1873 are each pinned here. +""" + +from __future__ import annotations + +from apm_cli.security.executables import ( + EXEC_TYPE_BIN, + EXEC_TYPE_HOOKS, + EXEC_TYPE_MCP, + LAYER_DEFAULT_DENY, + LAYER_ENFORCE_DEGRADED, + LAYER_GATE_DISABLED, + LAYER_ORG_DENY, + LAYER_ORG_DENY_ALL, + LAYER_ORG_RECOMMEND, + LAYER_PROJECT_ALLOW, + LAYER_PROJECT_DENY, + LAYER_USER_ALLOW, + LAYER_USER_DENY, + TRUST_DENIED, + TRUST_DEPLOYED, + TRUST_GATED, + ExecTrustContext, + exec_status_for_declaration, + materialize_exec_map, + resolve_exec_decision, +) + +PKG = "owner/repo#v1.0" +NAME = "owner/repo" + + +def _ctx(**kw) -> ExecTrustContext: + base = dict( + gate_enabled=True, + org_deny_all=False, + org_deny=frozenset(), + org_require=frozenset(), + org_recommend=frozenset(), + org_enforce=frozenset(), + org_bin_deny_all=False, + org_bin_deny=frozenset(), + project_allow={}, + project_deny={}, + user_allow={}, + user_deny={}, + ) + base.update(kw) + return ExecTrustContext(**base) + + +class TestGateDisabled: + def test_gate_disabled_allows_everything(self): + d = resolve_exec_decision(_ctx(gate_enabled=False), PKG, EXEC_TYPE_HOOKS) + assert d.allowed is True + assert d.deciding_layer == LAYER_GATE_DISABLED + assert d.trust_state == TRUST_DEPLOYED + + +class TestRow1OrgDeny: + def test_org_deny_all_denies_absolutely(self): + d = resolve_exec_decision(_ctx(org_deny_all=True), PKG, EXEC_TYPE_HOOKS) + assert d.allowed is False + assert d.deciding_layer == LAYER_ORG_DENY_ALL + assert d.trust_state == TRUST_DENIED + + def test_org_deny_list_denies(self): + d = resolve_exec_decision(_ctx(org_deny=frozenset({NAME})), PKG, EXEC_TYPE_MCP) + assert d.allowed is False + assert d.deciding_layer == LAYER_ORG_DENY + + def test_org_deny_beats_project_allow(self): + d = resolve_exec_decision( + _ctx(org_deny=frozenset({NAME}), project_allow={PKG: {EXEC_TYPE_HOOKS: True}}), + PKG, + EXEC_TYPE_HOOKS, + ) + assert d.allowed is False + assert d.deciding_layer == LAYER_ORG_DENY + + def test_legacy_bin_deploy_denies_bin_only(self): + ctx = _ctx(org_bin_deny=frozenset({NAME})) + assert resolve_exec_decision(ctx, PKG, EXEC_TYPE_BIN).allowed is False + # bin_deploy is bin-scoped: hooks are unaffected by the legacy alias. + d_hooks = resolve_exec_decision(ctx, PKG, EXEC_TYPE_HOOKS) + assert d_hooks.deciding_layer != LAYER_ORG_DENY_ALL + + +class TestRow2UserDeny: + def test_user_deny_denies(self): + d = resolve_exec_decision( + _ctx(user_deny={PKG: {EXEC_TYPE_HOOKS: True}}), PKG, EXEC_TYPE_HOOKS + ) + assert d.allowed is False + assert d.deciding_layer == LAYER_USER_DENY + + def test_user_deny_beats_org_recommend(self): + d = resolve_exec_decision( + _ctx(org_recommend=frozenset({NAME}), user_deny={PKG: {EXEC_TYPE_HOOKS: True}}), + PKG, + EXEC_TYPE_HOOKS, + ) + assert d.allowed is False + assert d.deciding_layer == LAYER_USER_DENY + + +class TestProjectDeny: + def test_project_deny_denies(self): + d = resolve_exec_decision( + _ctx(project_deny={PKG: {EXEC_TYPE_BIN: True}}), PKG, EXEC_TYPE_BIN + ) + assert d.allowed is False + assert d.deciding_layer == LAYER_PROJECT_DENY + + +class TestRows34EnforceDegrades: + def test_enforce_does_not_force_execute_in_v1(self): + # v1: enforce must NEVER force-allow ahead of a user opinion; it + # degrades to recommend (allow, user-overridable). With no user + # opinion it allows but is attributed to the degraded layer. + d = resolve_exec_decision(_ctx(org_enforce=frozenset({NAME})), PKG, EXEC_TYPE_HOOKS) + assert d.allowed is True + assert d.deciding_layer == LAYER_ENFORCE_DEGRADED + + def test_enforce_is_overridable_by_user_deny(self): + d = resolve_exec_decision( + _ctx(org_enforce=frozenset({NAME}), user_deny={PKG: {EXEC_TYPE_HOOKS: True}}), + PKG, + EXEC_TYPE_HOOKS, + ) + assert d.allowed is False + assert d.deciding_layer == LAYER_USER_DENY + + +class TestRow5ProjectAllow: + def test_project_allow_allows(self): + d = resolve_exec_decision( + _ctx(project_allow={PKG: {EXEC_TYPE_HOOKS: True}}), PKG, EXEC_TYPE_HOOKS + ) + assert d.allowed is True + assert d.deciding_layer == LAYER_PROJECT_ALLOW + assert d.trust_state == TRUST_DEPLOYED + + def test_project_allow_is_per_exec_type(self): + # Allowing hooks does not allow bin. + d = resolve_exec_decision( + _ctx(project_allow={PKG: {EXEC_TYPE_HOOKS: True}}), PKG, EXEC_TYPE_BIN + ) + assert d.allowed is False + assert d.deciding_layer == LAYER_DEFAULT_DENY + + def test_project_allow_matches_versionless_name(self): + d = resolve_exec_decision( + _ctx(project_allow={NAME: {EXEC_TYPE_HOOKS: True}}), PKG, EXEC_TYPE_HOOKS + ) + assert d.allowed is True + + +class TestRow6UserAllow: + def test_user_allow_allows(self): + d = resolve_exec_decision( + _ctx(user_allow={PKG: {EXEC_TYPE_HOOKS: True}}), PKG, EXEC_TYPE_HOOKS + ) + assert d.allowed is True + assert d.deciding_layer == LAYER_USER_ALLOW + + +class TestRow7OrgRecommend: + def test_org_recommend_allows_overridable(self): + d = resolve_exec_decision(_ctx(org_recommend=frozenset({NAME})), PKG, EXEC_TYPE_HOOKS) + assert d.allowed is True + assert d.deciding_layer == LAYER_ORG_RECOMMEND + assert d.trust_state == TRUST_DEPLOYED + + +class TestRow8DefaultDeny: + def test_no_opinion_default_denies_but_approvable(self): + d = resolve_exec_decision(_ctx(), PKG, EXEC_TYPE_HOOKS) + assert d.allowed is False + assert d.deciding_layer == LAYER_DEFAULT_DENY + # default-deny is approvable (gated), not hard denied. + assert d.trust_state == TRUST_GATED + + +class TestShadowedLayers: + def test_user_deny_records_org_recommend_as_shadowed(self): + d = resolve_exec_decision( + _ctx(org_recommend=frozenset({NAME}), user_deny={PKG: {EXEC_TYPE_HOOKS: True}}), + PKG, + EXEC_TYPE_HOOKS, + ) + assert LAYER_ORG_RECOMMEND in d.shadowed_layers + + +# ------------------------------------------------------------------- +# materialize_exec_map: deny-wins effective allow-map (issue #1873) +# ------------------------------------------------------------------- + + +class TestMaterializeExecMap: + def test_gate_disabled_returns_none(self): + assert materialize_exec_map(_ctx(gate_enabled=False)) is None + + def test_project_allow_emits_key_and_versionblind_alias(self): + m = materialize_exec_map(_ctx(project_allow={PKG: {EXEC_TYPE_HOOKS: True}})) + assert m is not None + # exact key is present and allowed for hooks only + assert m[PKG] == {EXEC_TYPE_HOOKS: True} + # version-blind name alias is emitted so any installed version matches + assert m[NAME] == {EXEC_TYPE_HOOKS: True} + + def test_org_deny_beats_project_allow_excluded_from_map(self): + # deny-wins: an org-denied package never lands in the allow-map. + m = materialize_exec_map( + _ctx( + project_allow={PKG: {EXEC_TYPE_HOOKS: True}}, + org_deny=frozenset({NAME}), + ) + ) + # gate is enabled (project block present) but the denied pair is absent. + assert m is not None + assert EXEC_TYPE_HOOKS not in m.get(PKG, {}) + assert EXEC_TYPE_HOOKS not in m.get(NAME, {}) + + def test_default_deny_yields_empty_map(self): + # gate enabled by a project deny entry, but nothing is allowed. + m = materialize_exec_map(_ctx(project_deny={PKG: {EXEC_TYPE_HOOKS: True}})) + assert m is not None + assert m == {} + + +# ------------------------------------------------------------------- +# exec_status_for_declaration: lockfile worst-case folding (Gap B) +# ------------------------------------------------------------------- + + +class TestExecStatusForDeclaration: + def test_no_exec_types_returns_none(self): + assert exec_status_for_declaration(_ctx(), [PKG], ()) is None + + def test_gate_disabled_returns_none(self): + assert ( + exec_status_for_declaration(_ctx(gate_enabled=False), [PKG], (EXEC_TYPE_HOOKS,)) is None + ) + + def test_all_allowed_is_deployed(self): + ctx = _ctx(project_allow={PKG: {EXEC_TYPE_HOOKS: True, EXEC_TYPE_BIN: True}}) + status = exec_status_for_declaration(ctx, [PKG], (EXEC_TYPE_HOOKS, EXEC_TYPE_BIN)) + assert status == TRUST_DEPLOYED + + def test_any_denied_folds_to_denied(self): + ctx = _ctx( + project_allow={PKG: {EXEC_TYPE_HOOKS: True}}, + org_deny=frozenset({NAME}), + ) + # hooks denied by org ceiling -> worst-case denied even if others allow. + status = exec_status_for_declaration(ctx, [PKG], (EXEC_TYPE_HOOKS, EXEC_TYPE_BIN)) + assert status == TRUST_DENIED + + def test_unapproved_folds_to_gated(self): + # gate enabled via project deny entry on a different key; the declared + # package has no opinion -> default-deny -> gated_pending_approval. + ctx = _ctx(project_deny={"other/pkg": {EXEC_TYPE_HOOKS: True}}) + status = exec_status_for_declaration(ctx, [PKG], (EXEC_TYPE_HOOKS,)) + assert status == TRUST_GATED diff --git a/tests/unit/test_lockfile_exec_status.py b/tests/unit/test_lockfile_exec_status.py new file mode 100644 index 000000000..e1882a7de --- /dev/null +++ b/tests/unit/test_lockfile_exec_status.py @@ -0,0 +1,42 @@ +"""Tests for the ``exec_status`` (trust_state) field on LockedDependency. + +``exec_status`` records the resolved executable-trust state of a package +(issue #1873): one of ``deployed`` | ``gated_pending_approval`` | ``denied`` +| ``absent``. Absence on disk is meaningful (the package never declared an +executable), so the field is OMITTED from the serialized entry when unset. +""" + +from __future__ import annotations + +import pytest + +from apm_cli.deps.lockfile import LockedDependency + + +def test_exec_status_defaults_to_none(): + dep = LockedDependency(repo_url="owner/repo") + assert dep.exec_status is None + + +def test_exec_status_roundtrips(): + dep = LockedDependency(repo_url="owner/repo", exec_status="gated_pending_approval") + data = dep.to_dict() + assert data["exec_status"] == "gated_pending_approval" + restored = LockedDependency.from_dict(data) + assert restored.exec_status == "gated_pending_approval" + + +def test_exec_status_omitted_when_absent(): + dep = LockedDependency(repo_url="owner/repo") + assert "exec_status" not in dep.to_dict() + + +def test_exec_status_not_treated_as_unknown_forward_field(): + dep = LockedDependency.from_dict({"repo_url": "owner/repo", "exec_status": "deployed"}) + assert dep.exec_status == "deployed" + assert "exec_status" not in dep._unknown_fields + + +def test_invalid_exec_status_rejected(): + with pytest.raises(ValueError, match="Unsupported lockfile exec_status"): + LockedDependency.from_dict({"repo_url": "owner/repo", "exec_status": "forged"})