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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- `apm audit` now surfaces unmanaged files in governance directories as a single enriched report: each finding states a factual reason (`not tracked in apm.lock.yaml`), a lazy primitive-type tag (`[type: skill|agent|instruction|mcp]`), and a deny-conflict note (`matches deny rule (<pattern>)`) when the path matches the policy's own `dependencies.deny` / `mcp.deny`. A new `unmanaged_files.exclude` policy key suppresses known harness-managed paths, and a symlink guard prevents following links out of the workspace. This is drift / divergence visibility, not supply-chain-attack prevention. (closes #1775) (#1793)
- Azure DevOps is now documented as a first-class marketplace authoring host: a `marketplace.sourceBase` of `https://dev.azure.com/{org}/{project}/_git` composes relative package sources and preserves the `dev.azure.com` host through to the consumer (authenticated with `ADO_APM_PAT`). The end-to-end authoring -> consume path is pinned by a hermetic test. (closes #1010) (#1810)
- `apm install --target antigravity` and `apm compile -t antigravity` add
Google Antigravity CLI (`agy`, successor to Gemini CLI) as a new target.
Expand Down
15 changes: 13 additions & 2 deletions CONFORMANCE.json
Original file line number Diff line number Diff line change
Expand Up @@ -642,6 +642,17 @@
"tests/spec_conformance/test_policy_reqs.py::test_policy_fail_on_drift_parses_and_is_specified"
]
},
{
"conformance_class": "governance",
"id": "req-pl-015",
"keyword": "MUST",
"section": "6.3.5",
"status": "active",
"test_count": 1,
"tests": [
"tests/spec_conformance/test_policy_reqs.py::test_unmanaged_files_surfacing_completeness"
]
},
{
"conformance_class": "consumer",
"id": "req-pr-001",
Expand Down Expand Up @@ -1019,7 +1030,7 @@
"xfail": 0
},
"governance": {
"active": 14,
"active": 15,
"skipped": 0,
"unbound": 0,
"xfail": 0
Expand All @@ -1037,5 +1048,5 @@
"xfail": 0
}
},
"total_requirements": 89
"total_requirements": 90
}
3 changes: 2 additions & 1 deletion CONFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ All four conformance classes (Producer, Consumer, Registry, Governance) carry ac
| Producer | 12 | 0 | 0 | 0 |
| Consumer | 61 | 1 | 0 | 0 |
| Registry | 1 | 0 | 0 | 0 |
| Governance | 14 | 0 | 0 | 0 |
| Governance | 15 | 0 | 0 | 0 |

## Per-requirement coverage

Expand Down Expand Up @@ -84,6 +84,7 @@ All four conformance classes (Producer, Consumer, Registry, Governance) carry ac
| [req-pl-012](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-012) | MUST | 6.1.1 | governance | active | 1 |
| [req-pl-013](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-013) | MUST | 6.8 | governance | active | 1 |
| [req-pl-014](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-014) | MUST | 6.8 | governance | active | 1 |
| [req-pl-015](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-015) | MUST | 6.3.5 | governance | active | 1 |
| [req-pr-001](docs/src/content/docs/specs/openapm-v0.1.md#req-pr-001) | MUST | 8.2 | consumer | active | 1 |
| [req-pr-002](docs/src/content/docs/specs/openapm-v0.1.md#req-pr-002) | MUST | 8.3 | consumer | active | 1 |
| [req-pr-003](docs/src/content/docs/specs/openapm-v0.1.md#req-pr-003) | MUST | 8.3 | consumer | active | 1 |
Expand Down
4 changes: 4 additions & 0 deletions docs/public/specs/manifests/openapm-v0.1.requirements.yml
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,10 @@ requirements:
keyword: MUST
section: "6.8"
conformance_class: governance
- id: req-pl-015
keyword: MUST
section: "6.3.5"
conformance_class: governance
- id: req-rs-001
keyword: MUST
section: "7.2"
Expand Down
35 changes: 35 additions & 0 deletions docs/src/content/docs/enterprise/policy-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,40 @@ unmanaged_files:
- .cursor/rules
- .claude
- .opencode
- .kiro
```

### `exclude`

Glob allow-list of workspace paths to suppress from the report. Use it to
silence known harness-managed artifacts that legitimately live in a governance
directory but are not APM-tracked. Excluded paths are never reported. Merges as
a union down an `extends:` chain.

```yaml
unmanaged_files:
action: warn
exclude:
- .github/copilot-instructions.md
- .claude/settings.local.json
```

### Enriched findings

Each reported file is annotated in place within the single unmanaged-files
report. This is drift / divergence visibility -- `apm.lock.yaml` is
hand-editable YAML -- not supply-chain-attack prevention:

- a factual reason: `not tracked in apm.lock.yaml`;
- a lazy primitive-type tag (`[type: skill|agent|instruction|mcp]`), computed
only for already-flagged files (a directory merely named `mcp` is not treated
as MCP config -- only a `.mcp/` root or an `mcp.json` file is);
- a deny-conflict note `matches deny rule (<pattern>)` when the path matches
this policy's own `dependencies.deny` or `mcp.deny`, surfaced for a human to
resolve. APM reports only -- it never removes or blocks the file.

```text
[!] .claude/skills/rogue/SKILL.md [type: skill] -- not tracked in apm.lock.yaml; matches deny rule (**/rogue/**)
```

---
Expand Down Expand Up @@ -445,6 +479,7 @@ A child policy can only tighten constraints — never relax them:
| `mcp.self_defined` | Escalates: `allow` < `warn` < `deny` |
| `manifest.scripts` | Escalates: `allow` < `deny` |
| `unmanaged_files.action` | Escalates: `ignore` < `warn` < `deny` |
| `unmanaged_files.exclude` | Union, additive-only -- child adds suppression globs to the parent set; `null` and `[]` both preserve the parent list (a child cannot clear it) |
| `source_attribution` | `parent OR child` — either enables it |
| `trust_transitive` | `parent AND child` — both must allow it |

Expand Down
19 changes: 19 additions & 0 deletions docs/src/content/docs/reference/policy-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,22 @@ Files in primitive target directories that are not recorded in `apm.lock.yaml`.
|---------------|----------------|----------|----------------------------------------------------------------------------------|
| `action` | enum | `ignore` | `ignore` / `warn` / `deny`. `deny` blocks installs that would leave drift. |
| `directories` | list of paths | `[]` | Subset of target directories to check. Empty = all known target directories. |
| `exclude` | list of globs | `null` | Workspace path globs to suppress from the report. Use to silence known harness-managed artifacts. Excluded paths are never reported. `null` = no opinion (transparent in the `extends:` merge); merges as a union down the chain. |

Each reported file is a divergence-visibility finding, not a security verdict
-- `apm.lock.yaml` is hand-editable YAML, so this surfaces drift rather than
proving a supply-chain attack. Every finding is enriched in place:

- a factual reason -- `not tracked in apm.lock.yaml`;
- a lazy primitive-type tag (`[type: skill|agent|instruction|mcp]`) classified
only for the already-flagged file, never the whole tree;
- a deny-conflict note -- `matches deny rule (<pattern>)` -- when the path
matches this policy's own `dependencies.deny` or `mcp.deny`. This is surfaced
for a human to resolve; APM never removes or blocks the file on this basis.

```text
[!] .github/agents/rogue.agent.md [type: agent] -- not tracked in apm.lock.yaml; matches deny rule (**/rogue*)
```

## security

Expand Down Expand Up @@ -269,6 +285,7 @@ inherited list (see the tri-state table below).
| `mcp.trust_transitive` | Logical AND (`true` only if both sides true). |
| `manifest.scripts` | Stricter wins (`deny` > `allow`). |
| `unmanaged_files.action` | Stricter wins (`deny` > `warn` > `ignore`). |
| `unmanaged_files.exclude` | Union, deduplicated; additive-only. `null` and `[]` both preserve the parent list -- unlike `deny`/`require`, a child cannot clear an inherited `exclude`. |
| `security.audit.on_install` | Stricter wins (`block` > `warn` > `off`). `null` is transparent. |
| `security.audit.external` | Union, deduplicated. `null` is transparent. |
| `security.audit.scanners` | Union of scanner names; per scanner `allow_args` is AND-merged (any ancestor `false` wins -- tightening). `null` is transparent. |
Expand Down Expand Up @@ -359,6 +376,8 @@ unmanaged_files:
directories:
- .github/instructions
- .github/prompts
exclude:
- .github/copilot-instructions.md
```

## registry_source
Expand Down
47 changes: 43 additions & 4 deletions docs/src/content/docs/specs/openapm-v0.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ between the companion corpus and the implementation.

### 1.3 Document conventions

- OpenAPM v0.1 carries **89 normative statements** indexed in
- OpenAPM v0.1 carries **90 normative statements** indexed in
[Appendix C](#appendix-c-index-of-normative-statements).
- All on-disk files defined by this specification are **YAML 1.2**
parsed under the safe subset defined in
Expand Down Expand Up @@ -1218,7 +1218,42 @@ requirement).
#### 6.3.5 `unmanaged_files`

The `unmanaged_files` block governs files in primitive target
directories that are not recorded in `apm.lock.yaml`.
directories that are not recorded in `apm.lock.yaml`. `directories`
names the managed primitive target trees to scan, `action` selects
the response (`ignore` | `warn` | `deny`), and `exclude` is a glob
allow-list of workspace paths to suppress from the report. Its glob
patterns are matched with the same pattern semantics as the policy
allow-list and deny-list fields (see
[Section 6.5](#65-allow-list--deny-list-tri-state-semantics)).

<a id="req-pl-015"></a>
**[req-pl-015]** A conforming **governance** implementation MUST,
when it evaluates policy over a populated primitive target tree (for
example during an audit), report unmanaged artifacts with the
following completeness guarantees:

(a) It MUST surface every file under a managed primitive target
directory that is neither recorded in `apm.lock.yaml` nor matched by
a configured `unmanaged_files.exclude` glob.

(b) Each surfaced path MUST be reported with the reason it is
unmanaged (that it is not tracked in `apm.lock.yaml`). Where the path
also matches a configured dependency or MCP deny pattern, the report
MUST additionally carry a supplemental conflict note naming that
pattern; this note is enrichment only and never itself causes a
tracked path to be surfaced. Where the primitive type is
determinable, the surfaced entry MUST carry its
inferred primitive type; where it is not determinable, the type
annotation MUST be omitted.

(c) A path matched by a configured `unmanaged_files.exclude` glob
MUST NOT be surfaced, even when it also matches a deny pattern.

This requirement governs the **completeness** of unmanaged-artifact
reporting only: whether a surfaced artifact yields a non-passing
audit result remains governed by `unmanaged_files.action` per the
merge table in [Section 6.4](#64-inheritance-and-merge-rules), so
req-pl-015 is not an enforcement claim.

#### 6.3.6 `security`

Expand Down Expand Up @@ -1277,6 +1312,7 @@ merge a policy chain per the following table:
| `mcp.trust_transitive` | Logical AND. |
| `manifest.scripts` | Stricter wins (`deny` > `allow`). |
| `unmanaged_files.action` | Stricter wins (`deny` > `warn` > `ignore`). |
| `unmanaged_files.exclude` | Union, deduplicated (byte-exact on each pattern's UTF-8 string), parent order preserved (additive: a child adds exclusions and cannot clear a parent's; `null` and `[]` both preserve the parent list). |
| `security.integrity.require_hashes` | Logical OR (once `true`, stays `true`). |
| `security.audit.fail_on_drift` | Logical OR (once `true`, stays `true`). |

Expand Down Expand Up @@ -1370,7 +1406,8 @@ This section's normative statements are:
[req-pl-007](#req-pl-007), [req-pl-008](#req-pl-008),
[req-pl-009](#req-pl-009), [req-pl-010](#req-pl-010),
[req-pl-011](#req-pl-011), [req-pl-012](#req-pl-012),
[req-pl-013](#req-pl-013), [req-pl-014](#req-pl-014).
[req-pl-013](#req-pl-013), [req-pl-014](#req-pl-014),
[req-pl-015](#req-pl-015).

---

Expand Down Expand Up @@ -2712,6 +2749,7 @@ renumbering of conformance classes.
| [req-pl-012](#req-pl-012) | MUST | 6.1.1 | governance |
| [req-pl-013](#req-pl-013) | MUST | 6.8 | governance |
| [req-pl-014](#req-pl-014) | MUST | 6.8 | governance |
| [req-pl-015](#req-pl-015) | MUST | 6.3.5 | governance |
| [req-rs-001](#req-rs-001) | MUST | 7.2 | consumer |
| [req-rs-002](#req-rs-002) | MUST | 7.3 | consumer |
| [req-rs-003](#req-rs-003) | MUST | 7.3 | consumer |
Expand Down Expand Up @@ -2747,7 +2785,7 @@ renumbering of conformance classes.
| [req-cf-001](#req-cf-001) | MUST | 12.5 | consumer |
| [req-cf-002](#req-cf-002) | MUST | 12.3 | consumer |

**Total normative statements: 89** (84 MUST, 5 SHOULD).
**Total normative statements: 90** (85 MUST, 5 SHOULD).

---

Expand All @@ -2760,6 +2798,7 @@ renumbering of conformance classes.
| 0.1.1 | 2026-05-24 | v1.1 editorial+defensive fold. Closed convergent round-2 followups: Section 12.3 CI-binding MUST-for-claim (req-cf-002); req-mf-019 class reclassification (producer -> consumer); three stale heading labels in req-cf-001 and Appendix E.4; depEntry oneOf source-key requirement plus new fixture `manifest/invalid-no-source-key.yml`; normative-count reconciliation across Section 1.3, Appendix C trailer, and this row; bare-hex pattern anchored to exactly 64 hex characters; req-sc-007 redaction scope extended to packed bundles, lockfiles, and audit records, plus producer secret-pattern refuse-to-pack rule; workspaces MUST-NOT-use in v0.1 (req-mf-021); nest-mode reject-in-v0.1 (req-rs-013); tag-name regex tightened to the semver.org 2.0.0 reference grammar; build-metadata tie-break rule (req-rs-014); mirror-tolerance editorial note (replicate-verbatim); req-rg-001 cross-references added in Section 7.5.1 and Section 10.5; bare-hex reader-tolerance deprecation horizon; interoperability informative note Section 6.1.2; conformance-summary precedence rule in Section 11.1; wildcard typo `x.y.x` -> `x.y.z`; resolved_by worked-example fragment in Section 7.4. Statement count: 83 -> 87. Drift-detection scaffolding lands in-spec and in-tree (informative machine-readable manifest at `docs/public/specs/manifests/openapm-v0.1.requirements.yml`; 4-way orphan_check + spec-conformance pytest suite + generated `CONFORMANCE.{md,json}` at repo root); Section 12.3 language updated to identify HTML anchors as the canonical source. No normative count change. |
| 0.1.2 | 2026-05-28 | Round-3 spec-guardian editorial fold (no new normative statements; statement count remains 87). Section 11.3.2 Consumer enumeration appended `[req-rs-014]` and `[req-cf-002]` (closing drift vs Appendix C). req-lk-005 extended: writers MUST canonicalise the `dependencies` list in ascending lexicographic order of (`repo_url`, `virtual_path`) so frozen-install diffs are stable across implementations. req-sc-003 extended: consumers MUST drop the originating Authorization header before issuing a cross-host-class redirect (closes the mirror-redirect token-leak surface in Section 10.3). req-rg-001 extended with publish-side idempotency clause: a Registry MUST either reject a republish or accept ONLY if bytes are byte-identical to the previously-served bytes. Section 6.2 + Section 6.3.1 defaults pinned: `fetch_failure` defaults to `warn` and `dependencies.require_resolution` defaults to `project-wins` (mirrored as advisory `"default"` annotations in `policy-v0.1.schema.json`). Manifest schema `conflict_resolution` enum aligned to prose: renamed `intersect` -> `intersection-pick`, dropped `nest` from the v0.1 enum (`nest` remains reserved-for-v0.2 per req-rs-013, now noted via schema `$comment`). Mode B silent-extension detector landed in `.github/workflows/spec-conformance.yml` and `tests/spec_conformance/mode_b_detector.sh`; closes the named sole-implementer rot risk by gating PRs that add substantive code under critical paths (`primitives/`, `deps/`, `policy/`, `registry/`, `runtime/`, `install/`, `integration/`) without a spec citation, with auditable `apm-spec-waiver:` opt-out. |
| 0.1.3 | 2026-06-16 | Spec-citation fold for the declarable integrity policy keys. Added two governance MUSTs under a new Section 6.8 "Integrity controls": [req-pl-013] (`security.integrity.require_hashes` -- fail-closed install when a resolved non-local dependency lacks a recorded hash in `apm.lock.yaml`, or the lockfile is absent/unreadable) and [req-pl-014] (`security.audit.fail_on_drift` -- audit exits non-zero on detected or indeterminate drift). Both keys are default-off and merge by logical OR (tighten-not-relax). Added the non-normative Section 6.3.6 `security` field reference and two merge-table rows; renumbered the governance conformance trailer 6.8 -> 6.9. Statement count: 87 -> 89 (84 MUST, 5 SHOULD). NOTE: a sibling spec-citation amendment also edits the shared count sites (Section 1.3, Appendix C trailer, this revision history); whichever lands second reconciles the cumulative total and takes the union of the added Appendix C rows. |
| 0.1.4 | 2026-06-16 | Normative addition (semver-zero `0.x` minor): added `[req-pl-015]` (Section 6.3.5, governance MUST) codifying unmanaged-artifact surfacing completeness -- a governance implementation evaluating policy over a populated primitive target tree MUST surface every file under a managed primitive target directory that is neither recorded in `apm.lock.yaml` nor matched by a configured `unmanaged_files.exclude` glob, each with its unmanaged reason and a supplemental dependency/MCP deny-conflict note where applicable; the inferred primitive type is carried where determinable and omitted otherwise; an excluded path MUST NOT be surfaced even when it also matches a deny pattern. The requirement body is structured as sub-clauses (a)/(b)/(c) so each obligation is individually citable. Added the `unmanaged_files.exclude` row to the Section 6.4 merge table (additive union, deduplicated, parent order preserved). The requirement governs reporting COMPLETENESS only; enforcement stays governed by `unmanaged_files.action`. Reconciled with the sibling 0.1.3 amendment (req-pl-013/req-pl-014): cumulative statement count 89 -> 90 (85 MUST, 5 SHOULD); Appendix C carries the union of all three new governance rows. |

Errata (none at publication).

Expand Down
1 change: 1 addition & 0 deletions packages/apm-guide/.apm/skills/apm-usage/governance.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ manifest:
unmanaged_files:
action: ignore # ignore | warn | deny
directories: [] # directories to scan
exclude: [] # path globs to suppress (known harness-managed files)

registry_source: # experimental: requires `apm experimental enable registries`
require: [] # registry names that MUST be reachable in the merged registry map
Expand Down
1 change: 1 addition & 0 deletions src/apm_cli/policy/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -1080,6 +1080,7 @@ def _opt_list(val: tuple[str, ...] | None) -> list | None:
"unmanaged_files": {
"action": policy.unmanaged_files.action,
"directories": list(policy.unmanaged_files.directories or ()),
"exclude": list(policy.unmanaged_files.exclude or ()),
},
}

Expand Down
9 changes: 7 additions & 2 deletions src/apm_cli/policy/inheritance.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ def _merge_unmanaged_files(
parent: UnmanagedFilesPolicy, child: UnmanagedFilesPolicy
) -> UnmanagedFilesPolicy:
"""Merge unmanaged-files policy; omitted child block is transparent (#1198)."""
if child.action is None and child.directories is None:
if child.action is None and child.directories is None and child.exclude is None:
return parent

if child.action is None:
Expand All @@ -274,10 +274,15 @@ def _merge_unmanaged_files(
child.directories,
)

if child.exclude is None:
eff_exclude = parent.exclude
else:
eff_exclude = _union(parent.exclude or (), child.exclude)

eff_action = eff_action_raw if eff_action_raw is not None else "ignore"
eff_dirs_out: tuple[str, ...] = () if eff_dirs is None else eff_dirs

return UnmanagedFilesPolicy(action=eff_action, directories=eff_dirs_out)
return UnmanagedFilesPolicy(action=eff_action, directories=eff_dirs_out, exclude=eff_exclude)


def _merge_security(parent: SecurityPolicy, child: SecurityPolicy) -> SecurityPolicy:
Expand Down
Loading
Loading