Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ your platform team can set, and what each one does to your install:
when `allow` is set) is rejected.
- **`dependencies.require`** -- packages your `apm.yml` must include.
- **`dependencies.max_depth`** -- maximum transitive dependency depth.
- **`dependencies.require_pinned_constraint`** -- when `true`, every
APM dep declared in your `apm.yml` must use a bounded constraint
(semver range, literal tag, or 40-char SHA); bare branch names,
wildcards, and open-upper ranges (`>=1.0.0`) are rejected.
- **`mcp.allow`** / **`mcp.deny`** -- glob patterns over MCP server
references. Same semantics as the dependency lists.
- **`mcp.transport.allow`** -- restricts MCP transports
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ dependencies:
require: [] # packages every repo must include
require_resolution: project-wins # project-wins | policy-wins | block
max_depth: 50
require_pinned_constraint: false # true = ban unbounded version ranges

mcp:
allow: null
Expand Down
3 changes: 2 additions & 1 deletion docs/src/content/docs/enterprise/governance-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ The scope matrix below is the contract. Every row maps a security or operational
| Required packages present | `dependencies.require` | `required-packages`, `required-packages-deployed` | Yes | Yes |
| Required package version | `dependencies.require[].version` | `required-package-version` | Yes | Yes |
| Transitive depth cap | `dependencies.max_depth` | `transitive-depth` | Yes (when `< 50`) | Yes |
| Pinned dep constraints | `dependencies.require_pinned_constraint` | `dependency-pinned-constraint` | Yes (when `true`) | Yes |
| MCP server allowlist | `mcp.allow` | `mcp-allowlist` | Yes (direct + transitive) | Yes |
| MCP server denylist | `mcp.deny` | `mcp-denylist` | Yes (direct + transitive) | Yes |
| MCP transport allowlist | `mcp.transport.allow` | `mcp-transport` | Yes | Yes |
Expand Down Expand Up @@ -185,7 +186,7 @@ Policies can extend other policies up to 5 levels deep (`MAX_CHAIN_DEPTH = 5`, e
graph TD
Hub["enterprise-hub-org/.github/<br/>apm-policy.yml<br/>broad allow lists,<br/>enforcement: warn"] --> Org["contoso/.github/<br/>apm-policy.yml<br/>extends: enterprise-hub-org<br/>adds deny + tightens<br/>enforcement: block"]
Org --> Repo["contoso/web-app/<br/>apm.yml policy stanza<br/>policy.hash pin,<br/>fetch_failure_default: block"]
Hub --> Merge["[*] merge_policies()<br/>tighten-only:<br/>allow=intersect, deny=union,<br/>enforcement=max(...),<br/>max_depth=min(...)"]
Hub --> Merge["[*] merge_policies()<br/>tighten-only:<br/>allow=intersect, deny=union,<br/>enforcement=max(...),<br/>max_depth=min(...),<br/>require_pinned_constraint=OR"]
Org --> Merge
Repo --> Merge
Merge --> Effective["Effective policy<br/>used by all 4<br/>enforcement points"]
Expand Down
17 changes: 16 additions & 1 deletion docs/src/content/docs/enterprise/policy-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ dependencies:
require: [] # Required packages
require_resolution: project-wins # project-wins | policy-wins | block
max_depth: 50 # Max transitive dependency depth
require_pinned_constraint: false # Ban unbounded version ranges

mcp:
allow: [] # Allowed MCP server patterns
Expand Down Expand Up @@ -164,6 +165,17 @@ dependencies:
max_depth: 3 # Direct + 2 levels of transitive
```

### `require_pinned_constraint`

Default: `false`. When `true`, every APM dependency declared in `apm.yml` must use a bounded constraint -- a semver range with an upper bound, a literal version tag (e.g. `v1.5.3`), or a 40-char commit SHA. Empty refs, bare branch names, wildcards (`*`, `1.x`), and open-upper ranges (`>=1.0.0`, `>1.0.0`) all fail the `dependency-pinned-constraint` check.

```yaml
dependencies:
require_pinned_constraint: true
```

Transitive deps are also classified; they pass when their parent manifests pinned them. See the [policy schema reference](../../reference/policy-schema/#require_pinned_constraint-reference) for the full classification table and diagnostic format.

---

## `mcp`
Expand Down Expand Up @@ -362,6 +374,7 @@ Deny patterns are evaluated first. If a reference matches any deny pattern, it f
| `required-packages-deployed` | Required packages appear in lockfile with deployed files |
| `required-package-version` | Required packages with version pins match per `require_resolution` |
| `transitive-depth` | No dependency exceeds `max_depth` |
| `dependency-pinned-constraint` | Every dep uses a bounded constraint (semver range, literal tag, or SHA) when `require_pinned_constraint: true` |

**MCP:**

Expand Down Expand Up @@ -420,6 +433,7 @@ A child policy can only tighten constraints — never relax them:
| `require` | Union — combines required packages |
| `require_resolution` | Escalates: `project-wins` < `policy-wins` < `block` |
| `max_depth` | `min(parent, child)` |
| `require_pinned_constraint` | OR -- once parent sets `true`, child cannot relax |
| `mcp.self_defined` | Escalates: `allow` < `warn` < `deny` |
| `manifest.scripts` | Escalates: `allow` < `deny` |
| `unmanaged_files.action` | Escalates: `ignore` < `warn` < `deny` |
Expand Down Expand Up @@ -549,7 +563,7 @@ Install-time enforcement and `apm audit --ci` both resolve the **full multi-leve

Install-time enforcement runs the same rule families documented in [Check reference](#check-reference):

- **Dependencies** — `allow`, `deny`, `require` (presence + optional version pin), `max_depth`.
- **Dependencies** — `allow`, `deny`, `require` (presence + optional version pin), `max_depth`, `require_pinned_constraint`.
- **MCP** — `allow`, `deny`, `transport.allow`, `self_defined`, `trust_transitive`.
- **Compilation** — `target.allow` / `target.enforce` (target-aware, evaluated against the resolved target list).
- **Manifest** — `required_fields`, `scripts`, `content_types.allow`.
Expand Down Expand Up @@ -862,6 +876,7 @@ When `enforcement=block`, any of the following exit `1` and abort before integra
| `required` | Missing `dependencies.require` entry, or pin mismatch | `Policy violation: <required-dep> -- required by org policy but not declared in apm.yml` (or `... required >=X but apm.yml pins <Y>`) | Add the required dep to `apm.yml` (and pin the required version). Pin mismatches downgrade to warn under `require_resolution: project-wins`; missing required deps still block. |
| `transport` | MCP transport not in `mcp.transport.allow` | `Policy violation: <mcp-server> -- transport <t> not in mcp.transport.allow=[<list>]` | Switch the server to an allowed transport, or request `mcp.transport.allow` updates. |
| `target` | Resolved target not in `compilation.target.allow` (or violates `target.enforce`) | `Policy violation: target <t> -- not in compilation.target.allow=[<list>]` | Re-run with `--target <allowed>`, or update `compilation.target` in `apm.yml`. Evaluated post-`targets` phase, so CLI overrides are honoured. |
| `pinned-constraint` | `require_pinned_constraint: true` and a dep declares an unbounded ref | `Policy violation: N dependency(ies) use unbounded constraints (hint: pin to a semver range, literal tag, or SHA)` plus per-dep `<dep>: <reason>` | Pin each listed dep to a semver range with an upper bound (`^1.2.3`, `>=1.0,<2.0`), a literal tag (`v1.5.3`), or a 40-char SHA. Roll out under `enforcement: warn` first to size the fleet impact. |
| `transitive_mcp` | MCP server pulled in by a transitive dep, blocked by `mcp.deny`/`transport`/`self_defined` | `Transitive MCP server(s) blocked by org policy. APM packages remain installed; MCP configs were NOT written.` plus per-server `Policy violation: ...` | Remove the offending dep, request an org policy update, or set `mcp.trust_transitive: true` if the org chooses to allow transitive MCP entries. |

All violation messages above flow through `InstallLogger.policy_violation`; under `block` they print inline as `[x]` errors and exit `1`. Use `apm audit --ci --format json` for the same set of findings in machine-readable form.
Expand Down
34 changes: 34 additions & 0 deletions docs/src/content/docs/reference/policy-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,39 @@ Rules over the `dependencies:` and `mcp:` blocks declared in consumer `apm.yml`
| `require` | list of refs or null | `null` | `null` = no opinion (transparent during merge). `[]` = explicitly empty. Packages every consumer manifest must include. |
| `require_resolution` | enum | `project-wins` | `project-wins` / `policy-wins` / `block` -- how to resolve version conflicts on required packages. |
| `max_depth` | integer | `50` | Maximum transitive dependency depth. Must be `> 0`. |
| `require_pinned_constraint` | boolean | `false` | When `true`, every APM dep declared in `apm.yml` must use a bounded constraint (exact, `^`/`~`/bounded range, literal tag, or SHA). Transitive deps are also classified and pass when their parent manifests pinned them. Unbounded refs (missing ref, `*`, bare branch, bare `>=X.Y`) are routed through `policy.enforcement` (`warn` / `block`). **Enabling on existing projects will likely surface violations; roll out with `enforcement: warn` first.** |

### `require_pinned_constraint` reference

Examples (with `require_pinned_constraint: true` and `enforcement: block`):

```yaml
# apm.yml
dependencies:
apm:
- acme/skills # FAIL: no ref (NO_REF)
- other/lib#>=1.0.0 # FAIL: unbounded upper (OPEN_UPPER)
- third/lib#* # FAIL: wildcard (WILDCARD)
- acme/lib#main # FAIL: bare branch (BARE_BRANCH)
- fourth/lib#^1.2.0 # OK: caret range
- fifth/lib#~1.2.3 # OK: tilde range
- sixth/lib#1.5.3 # OK: exact version
- seventh/lib#v1.5.3 # OK: literal tag
- eighth/lib#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # OK: SHA
- ./packages/local # OK: local-path dep (no version surface)
```

Diagnostic shape (ASCII-only, ``[x]`` for block, ``[!]`` for warn):

```text
[x] Policy violation: dependency-pinned-constraint
4 dependency(ies) use unbounded constraints
(hint: pin to a semver range, literal tag, or SHA)
- acme/skills: no ref; resolves to default branch
- other/lib: unbounded upper; pair with '<X.Y' or use a caret range
- third/lib: wildcard '*' matches any version
- acme/lib: bare branch 'main' tracks a moving tip
```

Patterns are matched against `<owner>/<repo>` (or `<host>/<owner>/<repo>`). Wildcards via shell-style globs, e.g. `contoso/*`.

Expand Down Expand Up @@ -141,6 +174,7 @@ inherited list (see the tri-state table below).
| `*.deny` / `require` lists | Union, deduplicated, parent order preserved. Omitting the field (or setting it to `null`) is transparent -- the parent value passes through unchanged. `[]` is an explicit empty override. |
| `dependencies.max_depth` | `min(parent, child)`. |
| `dependencies.require_resolution` | Stricter wins (`block` > `policy-wins` > `project-wins`). |
| `dependencies.require_pinned_constraint` | Logical OR -- once a parent enables it, child cannot relax. |
| `mcp.self_defined` | Stricter wins (`deny` > `warn` > `allow`). |
| `mcp.trust_transitive` | Logical AND (`true` only if both sides true). |
| `manifest.scripts` | Stricter wins (`deny` > `allow`). |
Expand Down
3 changes: 3 additions & 0 deletions packages/apm-guide/.apm/skills/apm-usage/governance.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ dependencies:
require: [] # required packages
require_resolution: project-wins # project-wins | policy-wins | block
max_depth: 50 # transitive depth limit
require_pinned_constraint: false # when true, ban unbounded dep ranges (NO_REF, '*', bare branch, '>=X' without upper bound)

mcp:
allow: [] # allowed server patterns
Expand Down Expand Up @@ -90,6 +91,7 @@ list (removing entries the parent set). All other fields obey the rules below:
| Deny lists | Union (child adds to parent). Omitting or `null` = transparent; `[]` = explicit empty override. |
| `require` | Union (combines required packages). Omitting or `null` = transparent; `[]` = explicit empty override. |
| `max_depth` | `min(parent, child)` |
| `require_pinned_constraint` | Logical OR (once enabled, child cannot relax) |
| `mcp.self_defined` | Escalates: `allow` < `warn` < `deny` |
| `source_attribution` | `parent OR child` (either enables) |

Expand Down Expand Up @@ -382,6 +384,7 @@ Violation classes:
| `denylist` | `dependencies.deny` match | Remove dep from `apm.yml`, request org-policy update, or `--no-policy` for one-off bypass |
| `allowlist` | Dep not in non-empty `dependencies.allow` | Add to org allowlist or switch to an approved package |
| `required` | Missing `dependencies.require` entry, or version-pin mismatch | Add the dep (and pin) to `apm.yml`. Pin mismatches downgrade to warn under `require_resolution: project-wins`; missing required deps still block |
| `pinned-constraint` | `dependencies.require_pinned_constraint: true` + a direct dep with no ref, a wildcard, a bare branch, or a bare `>=X.Y` | Pin the dep to an exact version, caret/tilde/bounded semver range, literal `vX.Y.Z` tag, or a full SHA. Roll out enforcement with `warn` before `block`. |
| `transport` | MCP transport not in `mcp.transport.allow` | Switch transport, or request `mcp.transport.allow` update |
| `target` | Resolved target not in `compilation.target.allow` (or violates `target.enforce`) | Re-run with `--target <allowed>`, or adjust `compilation.target` in `apm.yml` |
| `transitive_mcp` | MCP server pulled in by a transitive dep, blocked by `mcp.deny` / `transport` / `self_defined` | Remove offending dep, request policy update, or set `mcp.trust_transitive: true` |
Expand Down
1 change: 1 addition & 0 deletions src/apm_cli/install/phases/policy_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ def run(ctx: InstallContext) -> None:
effective_target=None, # target-aware checks after targets phase
fetch_outcome=fetch_result.outcome,
fail_fast=(enforcement == "block"),
direct_dep_keys={d.get_unique_key() for d in getattr(ctx, "all_apm_deps", []) or []},
**extra_kwargs,
)

Expand Down
1 change: 1 addition & 0 deletions src/apm_cli/install/phases/policy_target_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ def run(ctx: InstallContext) -> None:
fetch_outcome=ctx.policy_fetch.outcome,
fail_fast=False, # ensure target check runs even if dep checks re-pass
registries=registries_map,
direct_dep_keys={d.get_unique_key() for d in getattr(ctx, "all_apm_deps", []) or []},
)

# ------------------------------------------------------------------
Expand Down
Loading
Loading