diff --git a/.apm/architecture/owners/contracts-tooling.json b/.apm/architecture/owners/contracts-tooling.json index 31ef286093..7b48363743 100644 --- a/.apm/architecture/owners/contracts-tooling.json +++ b/.apm/architecture/owners/contracts-tooling.json @@ -4,11 +4,13 @@ { "id": "dependency-identity-materialization", "decision": "Dependency comparison identity and policy casing vs display-cased materialization path; embedded git URL subpath validation", - "owner": "models/dependency/identity.py + materialization.py + DependencyReference (_check_no_embedded_subpath consumes core/host_providers.py)", + "owner": "models/dependency/identity.py + materialization.py + object_fields.py (alias validation) + deps/apm_resolver.py (remote source-coordinate anchoring) + DependencyReference (_check_no_embedded_subpath consumes core/host_providers.py)", "selectors": [ "src/apm_cli/models/dependency/identity.py", "src/apm_cli/models/dependency/materialization.py", - "src/apm_cli/models/dependency/reference.py" + "src/apm_cli/models/dependency/object_fields.py", + "src/apm_cli/models/dependency/reference.py", + "src/apm_cli/deps/apm_resolver.py" ], "guards": ["contracts-tooling-dependency-identity", "contracts-tooling-policy-identity"] }, @@ -60,7 +62,7 @@ { "id": "read-only-lockfile-path", "decision": "Lockfile read path, timestamp emission, and reproducible fallback", - "owner": "deps/lockfile.py (resolve_lockfile_path_for_read, LockFile.write, resolve_reproducible_timestamp)", + "owner": "deps/lockfile.py (resolve_lockfile_path_for_read, LockFile.write, resolve_reproducible_timestamp, LockedDependency alias projection)", "selectors": ["src/apm_cli/deps/lockfile.py"], "guards": [ "contracts-tooling-lockfile-read", diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ef4721095..2229d8b9d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Inactive `specs/openapm-v0.2.md` corrective draft for local-source admission and read-only current-intent audit; v0.1 remains active, runtime work is tracked in #2919 and #2923, and human ratification remains pending. (#2820) - gh-aw's shared APM import now supports `token-source: github-token`; after consumers re-vendor the workflow, its read-only current-repository identity can fetch same-repository private packages, while `cascade` remains the default and cross-repository packages still require a dedicated token or GitHub App. (#2706) - OpenAPM v0.1 adds `req-pl-018` for dependency-policy identity casing and amends `req-rs-016` clause (3), the Section 6.4 merge rules, and the Section 6.5 pattern grammar so repository identity and policy matching cannot diverge; Section 11.2 item 6 now requires the per-host case rule in `CONFORMANCE.md`. (#2706) @@ -20,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- Dependency aliases now reject `.` and `..`, require contained destinations, and persist placement for replay and cleanup; documented in `specs/openapm-v0.1.md` (`req-mf-025`). [Review alias migration and reinstall artifacts](https://microsoft.github.io/apm/troubleshooting/migration/#rejected-dependency-aliases). - by @Danvs60 (#2901) - The shared gh-aw APM pack job now declares `contents: read` (previously `permissions: {}`), the minimum the explicit built-in-token path needs. No write scope is added, and the token is not forwarded to restore or agent jobs. (#2706) - Dependency policy `allow`, `deny`, and exact `require` matching now follows canonical owner/repository casing, fixing mixed-case blocks and deny fail-open behavior while retaining lazy shared required-package lookup. APM 0.30.0 and earlier match patterns byte-exactly against the lowercased identity; lowercase patterns keep matching in every release, so drop workaround duplicates only after every runner uses a release carrying this fix. (#2706) diff --git a/CONFORMANCE.json b/CONFORMANCE.json index b2fbc74e4d..0023b549b3 100644 --- a/CONFORMANCE.json +++ b/CONFORMANCE.json @@ -580,6 +580,61 @@ "tests/spec_conformance/test_manifest_reqs.py::test_consumer_preserves_registry_identity_on_structured_rewrite" ] }, + { + "conformance_class": "consumer", + "id": "req-mf-025", + "keyword": "MUST", + "section": "4.3.2", + "status": "active", + "test_count": 45, + "tests": [ + "tests/spec_conformance/test_alias_reqs.py::test_absent_alias_is_not_inferred_from_inventory_name", + "tests/spec_conformance/test_alias_reqs.py::test_alias_rejects_symlink_destination[outside]", + "tests/spec_conformance/test_alias_reqs.py::test_alias_rejects_symlink_destination[root]", + "tests/spec_conformance/test_alias_reqs.py::test_alias_schemas_distinguish_input_from_canonical_output[ \\r..\\t-False-False]", + "tests/spec_conformance/test_alias_reqs.py::test_alias_schemas_distinguish_input_from_canonical_output[ \\tMy.Safe\\n-True-False]", + "tests/spec_conformance/test_alias_reqs.py::test_alias_schemas_distinguish_input_from_canonical_output[ \\t\\n-False-False]", + "tests/spec_conformance/test_alias_reqs.py::test_alias_schemas_distinguish_input_from_canonical_output[-False-False]", + "tests/spec_conformance/test_alias_reqs.py::test_alias_schemas_distinguish_input_from_canonical_output[.-False-False]", + "tests/spec_conformance/test_alias_reqs.py::test_alias_schemas_distinguish_input_from_canonical_output[..-False-False]", + "tests/spec_conformance/test_alias_reqs.py::test_alias_schemas_distinguish_input_from_canonical_output[../escape-False-False]", + "tests/spec_conformance/test_alias_reqs.py::test_alias_schemas_distinguish_input_from_canonical_output[..\\n-False-False]", + "tests/spec_conformance/test_alias_reqs.py::test_alias_schemas_distinguish_input_from_canonical_output[.safe-True-True]", + "tests/spec_conformance/test_alias_reqs.py::test_alias_schemas_distinguish_input_from_canonical_output[1-False-False]", + "tests/spec_conformance/test_alias_reqs.py::test_alias_schemas_distinguish_input_from_canonical_output[foo..bar-True-True]", + "tests/spec_conformance/test_alias_reqs.py::test_alias_schemas_distinguish_input_from_canonical_output[my-skill.v2-True-True]", + "tests/spec_conformance/test_alias_reqs.py::test_alias_schemas_distinguish_input_from_canonical_output[safe.-True-True]", + "tests/spec_conformance/test_alias_reqs.py::test_alias_schemas_distinguish_input_from_canonical_output[safe/name-False-False]", + "tests/spec_conformance/test_alias_reqs.py::test_alias_schemas_distinguish_input_from_canonical_output[safe\\n-True-False]", + "tests/spec_conformance/test_alias_reqs.py::test_alias_schemas_distinguish_input_from_canonical_output[safe\\ninside-False-False]", + "tests/spec_conformance/test_alias_reqs.py::test_alias_survives_lock_replay_without_changing_source[git- \\tMy.Safe\\n]", + "tests/spec_conformance/test_alias_reqs.py::test_alias_survives_lock_replay_without_changing_source[git-.safe]", + "tests/spec_conformance/test_alias_reqs.py::test_alias_survives_lock_replay_without_changing_source[git-None]", + "tests/spec_conformance/test_alias_reqs.py::test_alias_survives_lock_replay_without_changing_source[git-foo..bar]", + "tests/spec_conformance/test_alias_reqs.py::test_alias_survives_lock_replay_without_changing_source[git-my-skill.v2]", + "tests/spec_conformance/test_alias_reqs.py::test_alias_survives_lock_replay_without_changing_source[git-safe.]", + "tests/spec_conformance/test_alias_reqs.py::test_alias_survives_lock_replay_without_changing_source[local- \\tMy.Safe\\n]", + "tests/spec_conformance/test_alias_reqs.py::test_alias_survives_lock_replay_without_changing_source[local-.safe]", + "tests/spec_conformance/test_alias_reqs.py::test_alias_survives_lock_replay_without_changing_source[local-None]", + "tests/spec_conformance/test_alias_reqs.py::test_alias_survives_lock_replay_without_changing_source[local-foo..bar]", + "tests/spec_conformance/test_alias_reqs.py::test_alias_survives_lock_replay_without_changing_source[local-my-skill.v2]", + "tests/spec_conformance/test_alias_reqs.py::test_alias_survives_lock_replay_without_changing_source[local-safe.]", + "tests/spec_conformance/test_alias_reqs.py::test_alias_survives_lock_replay_without_changing_source[registry- \\tMy.Safe\\n]", + "tests/spec_conformance/test_alias_reqs.py::test_alias_survives_lock_replay_without_changing_source[registry-.safe]", + "tests/spec_conformance/test_alias_reqs.py::test_alias_survives_lock_replay_without_changing_source[registry-None]", + "tests/spec_conformance/test_alias_reqs.py::test_alias_survives_lock_replay_without_changing_source[registry-foo..bar]", + "tests/spec_conformance/test_alias_reqs.py::test_alias_survives_lock_replay_without_changing_source[registry-my-skill.v2]", + "tests/spec_conformance/test_alias_reqs.py::test_alias_survives_lock_replay_without_changing_source[registry-safe.]", + "tests/spec_conformance/test_alias_reqs.py::test_invalid_lock_alias_guidance_repairs_the_lock_not_the_manifest", + "tests/spec_conformance/test_alias_reqs.py::test_lock_alias_rejects_invalid_destination_names[%2e%2e]", + "tests/spec_conformance/test_alias_reqs.py::test_lock_alias_rejects_invalid_destination_names[../escape]", + "tests/spec_conformance/test_alias_reqs.py::test_lock_alias_rejects_invalid_destination_names[..]", + "tests/spec_conformance/test_alias_reqs.py::test_lock_alias_rejects_invalid_destination_names[.]", + "tests/spec_conformance/test_alias_reqs.py::test_lock_alias_rejects_invalid_destination_names[1]", + "tests/spec_conformance/test_alias_reqs.py::test_lock_alias_rejects_invalid_destination_names[]", + "tests/spec_conformance/test_alias_reqs.py::test_lock_alias_rejects_invalid_destination_names[foo/../bar]" + ] + }, { "conformance_class": "governance", "id": "req-pl-001", @@ -1426,7 +1481,7 @@ "spec_version": "v0.1.1", "summary_by_class": { "consumer": { - "active": 90, + "active": 91, "skipped": 1, "unbound": 0, "xfail": 0 @@ -1450,5 +1505,5 @@ "xfail": 0 } }, - "total_requirements": 122 + "total_requirements": 123 } diff --git a/CONFORMANCE.md b/CONFORMANCE.md index a6c0ce79fd..b67df71335 100644 --- a/CONFORMANCE.md +++ b/CONFORMANCE.md @@ -29,7 +29,7 @@ Repository-coordinate segments are case-insensitive for `github.com`, GitHub Ent | Class | Active | Skipped | Xfail | Unbound | |-------|-------:|--------:|------:|--------:| | Producer | 12 | 0 | 0 | 0 | -| Consumer | 90 | 1 | 0 | 0 | +| Consumer | 91 | 1 | 0 | 0 | | Registry | 1 | 0 | 0 | 0 | | Governance | 18 | 0 | 0 | 0 | @@ -87,6 +87,7 @@ Repository-coordinate segments are case-insensitive for `github.com`, GitHub Ent | [req-mf-022](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-022) | MUST | 4.3.2 | consumer | active | 2 | - | | [req-mf-023](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-023) | MUST | 4.5 | consumer | active | 1 | - | | [req-mf-024](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-024) | MUST | 4.3.2 | consumer | active | 1 | - | +| [req-mf-025](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-025) | MUST | 4.3.2 | consumer | active | 45 | - | | [req-pl-001](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-001) | MUST | 6.1 | governance | active | 1 | - | | [req-pl-002](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-002) | MUST | 6.2 | governance | active | 1 | - | | [req-pl-003](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-003) | MUST | 6.4 | governance | active | 1 | - | diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index c9554c293d..8e7efcc77e 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -73,6 +73,10 @@ export default defineConfig({ '/spec': '/apm/specs/openapm-v01/', '/spec/latest': '/apm/specs/openapm-v01/', '/spec/v0.1': '/apm/specs/openapm-v01/', + // Exact revision slug is pinned; a later patch needs a distinct artifact. + // Keep /spec and /spec/latest unchanged until actual ratification. + '/spec/v0.2': '/apm/specs/openapm-v020/', + '/spec/v0.2.0': '/apm/specs/openapm-v020/', }, integrations: [ sitemap(), @@ -295,6 +299,7 @@ export default defineConfig({ // (/spec, /spec/v0.1, /spec/latest) bridge this in the // redirects block above for external citers. { label: 'OpenAPM v0.1', slug: 'specs/openapm-v01' }, + { label: 'OpenAPM v0.2.0 (draft)', slug: 'specs/openapm-v020' }, { label: 'Conformance', slug: 'specs/conformance' }, ], }, diff --git a/docs/public/specs/manifests/openapm-v0.1.requirements.yml b/docs/public/specs/manifests/openapm-v0.1.requirements.yml index c6f7844c56..24576982dc 100644 --- a/docs/public/specs/manifests/openapm-v0.1.requirements.yml +++ b/docs/public/specs/manifests/openapm-v0.1.requirements.yml @@ -119,6 +119,10 @@ requirements: keyword: MUST section: "4.3.2" conformance_class: consumer + - id: req-mf-025 + keyword: MUST + section: "4.3.2" + conformance_class: consumer - id: req-ext-001 keyword: MUST section: "4.1" diff --git a/docs/public/specs/manifests/openapm-v0.2.requirements.yml b/docs/public/specs/manifests/openapm-v0.2.requirements.yml new file mode 100644 index 0000000000..931f7429e6 --- /dev/null +++ b/docs/public/specs/manifests/openapm-v0.2.requirements.yml @@ -0,0 +1,547 @@ +# OpenAPM v0.2.0 -- informative requirements manifest (draft). +# +# Inherits the prospective #2901 inventory at exact commit +# 5a0d872b9dd63b78b08923eddfad0ed1f9b369b4, not a published or ratified baseline. +# Projects ID/keyword/section/class from the spec body's canonical anchors; +# Appendix C derives from the same source. +# +# Do NOT edit this file alone. Before selecting this draft for an +# executable assessment, the complete binding ritual is: +# 1. Add the anchor + prose to openapm-v0.2.md +# and the corresponding Appendix C row. +# 2. Add the entry below. +# 3. Add or extend a test under tests/spec_conformance/ with +# @pytest.mark.req("req-XXX") (and optionally cite a fixture +# path). +# #2919 owns step 3 and the coupled local-source/audit assessment; #2923 owns +# aggregation. #2820 is foundation-only. This inactive inventory does not switch +# the active v0.1 selector or claim passing bindings or runtime checks. +# +# Informative candidate CI input; Section 12.6's normative wire-format reservation +# remains in place. requirements-v0.1.schema.json describes this unchanged +# format, not the assessed spec version. Publication and ratification are pending. + +requirements_format_version: "1" +spec_version: "v0.2.0" +requirements: + - id: req-mf-001 + keyword: MUST + section: "4.1" + conformance_class: producer + - id: req-mf-002 + keyword: MUST + section: "4.1" + conformance_class: producer + - id: req-mf-003 + keyword: MUST + section: "4.1" + conformance_class: producer + - id: req-mf-004 + keyword: SHOULD + section: "4.1" + conformance_class: producer + - id: req-mf-005 + keyword: MUST + section: "4.2.1" + conformance_class: producer + - id: req-mf-006 + keyword: MUST + section: "4.1" + conformance_class: consumer + - id: req-mf-007 + keyword: MUST + section: "4.3.1" + conformance_class: consumer + - id: req-mf-008 + keyword: MUST + section: "4.3.3" + conformance_class: consumer + - id: req-mf-009 + keyword: MUST + section: "4.3.4" + conformance_class: consumer + - id: req-mf-010 + keyword: MUST + section: "4.3.2" + conformance_class: consumer + - id: req-mf-011 + keyword: MUST + section: "4.3.2" + conformance_class: consumer + - id: req-mf-012 + keyword: MUST + section: "4.3.6" + conformance_class: consumer + - id: req-mf-013 + keyword: MUST + section: "4.5" + conformance_class: consumer + - id: req-mf-014 + keyword: MUST + section: "4.2.3" + conformance_class: producer + - id: req-mf-015 + keyword: MUST + section: "4.2.3" + conformance_class: producer + - id: req-mf-016 + keyword: MUST + section: "4.3.5" + conformance_class: consumer + notes: "Clauses (a)-(d) govern source anchoring, user-scope admission, remote-repository containment, and local-content symlinks. Remote _local/ coordinates do not establish local provenance. The combined successor owns executable bindings against v0.2.0, not the previous minor's blanket project-root refusal; this inactive foundation claims no completed binding." + - id: req-mf-017 + keyword: MUST + section: "4.7" + conformance_class: producer + - id: req-mf-018 + keyword: MUST + section: "4.6.1" + conformance_class: consumer + - id: req-mf-019 + keyword: MUST + section: "4.2.4" + conformance_class: consumer + - id: req-mf-020 + keyword: MUST + section: "4.1" + conformance_class: consumer + - id: req-mf-021 + keyword: MUST + section: "4.8" + conformance_class: producer + - id: req-mf-022 + keyword: MUST + section: "4.3.2" + conformance_class: consumer + - id: req-mf-023 + keyword: MUST + section: "4.5" + conformance_class: consumer + - id: req-mf-024 + keyword: MUST + section: "4.3.2" + conformance_class: consumer + - id: req-mf-025 + keyword: MUST + section: "4.3.2" + conformance_class: consumer + - id: req-ext-001 + keyword: MUST + section: "4.1" + conformance_class: consumer + - id: req-ext-002 + keyword: MUST + section: "4.1" + conformance_class: producer + - id: req-lk-001 + keyword: MUST + section: "5.1" + conformance_class: consumer + - id: req-lk-002 + keyword: MUST + section: "5.4" + conformance_class: consumer + - id: req-lk-003 + keyword: MUST + section: "5.2" + conformance_class: consumer + notes: "full-SHA manifest pins must match resolved_commit during conformance audit" + - id: req-lk-004 + keyword: MUST + section: "5.4" + conformance_class: consumer + - id: req-lk-005 + keyword: MUST + section: "5.5" + conformance_class: consumer + notes: "generated_at is optional advisory metadata; consumers omit it from new lockfiles by default and preserve an existing omission unless explicitly configured otherwise" + - id: req-lk-006 + keyword: MUST + section: "5.5" + conformance_class: consumer + notes: "frozen validation covers direct package pins and MCP state before durable mutation" + - id: req-lk-007 + keyword: SHOULD + section: "5.5" + conformance_class: consumer + - id: req-lk-008 + keyword: MUST + section: "5.6" + conformance_class: consumer + - id: req-lk-009 + keyword: MUST + section: "5.6" + conformance_class: consumer + - id: req-lk-010 + keyword: MUST + section: "5.6" + conformance_class: consumer + - id: req-lk-011 + keyword: MUST + section: "5.2" + conformance_class: consumer + - id: req-lk-012 + keyword: MUST + section: "5.2" + conformance_class: consumer + - id: req-lk-013 + keyword: MUST + section: "5.2" + conformance_class: consumer + - id: req-lk-014 + keyword: MUST + section: "5.2" + conformance_class: consumer + - id: req-lk-015 + keyword: MUST + section: "5.6.4" + conformance_class: consumer + - id: req-lk-016 + keyword: MUST + section: "5.2" + conformance_class: consumer + - id: req-lk-017 + keyword: MUST + section: "5.2" + conformance_class: consumer + - id: req-lk-018 + keyword: SHOULD + section: "5.5" + conformance_class: consumer + - id: req-lk-019 + keyword: MUST + section: "5.2" + conformance_class: consumer + notes: "name and dependency-apm.yml-derived version are self-asserted inventory metadata, never trust anchors" + - id: req-lk-020 + keyword: MUST + section: "5.2" + conformance_class: consumer + notes: "target and identity reconciliation preserves legitimate, indeterminate, and freshly redeployed paths while dropping inactive-target ghosts" + - id: req-lk-021 + keyword: MUST + section: "5.2" + conformance_class: consumer + notes: "extends req-lk-020's preserve/remove decision to merge-based hook configuration and its ownership record" + - id: req-lk-022 + keyword: MUST + section: "5.2" + conformance_class: consumer + notes: "source-cased materialization_repo_url remains non-identity metadata and drives rollback-safe, collision-closed materialization/link spelling" + - id: req-lk-023 + keyword: MUST + section: "5.5" + conformance_class: consumer + notes: "Current manifest then selected saved configuration then detection determines source-derived replay; prior claims widen comparison only. Live project/user configuration, deployment bytes, native databases and sidecars stay unchanged. Unsupported native replay fails before its writer. The combined successor owns complete replay bindings, including explicit-only selection, historical native ownership refusal, and internal-resource-link composition; invalid-owner attribution remains req-pl-016. This inactive foundation claims no completed binding or successful runtime assessment." + - id: req-pl-001 + keyword: MUST + section: "6.1" + conformance_class: governance + - id: req-pl-002 + keyword: MUST + section: "6.2" + conformance_class: governance + - id: req-pl-003 + keyword: MUST + section: "6.4" + conformance_class: governance + - id: req-pl-004 + keyword: MUST + section: "6.4" + conformance_class: governance + - id: req-pl-005 + keyword: MUST + section: "6.5" + conformance_class: governance + - id: req-pl-006 + keyword: MUST + section: "6.4" + conformance_class: governance + - id: req-pl-007 + keyword: MUST + section: "6.3.1" + conformance_class: governance + - id: req-pl-008 + keyword: MUST + section: "6.3.1" + conformance_class: governance + - id: req-pl-009 + keyword: MUST + section: "6.6" + conformance_class: governance + - id: req-pl-010 + keyword: MUST + section: "6.2" + conformance_class: governance + - id: req-pl-011 + keyword: MUST + section: "6.1.1" + conformance_class: governance + - id: req-pl-012 + keyword: MUST + section: "6.1.1" + conformance_class: governance + - id: req-pl-013 + keyword: MUST + section: "6.8" + conformance_class: governance + - id: req-pl-014 + keyword: MUST + section: "6.8" + conformance_class: governance + - id: req-pl-015 + keyword: MUST + section: "6.3.5" + conformance_class: governance + - id: req-pl-016 + keyword: MUST + section: "6.8" + conformance_class: governance + notes: "a canonical deployment-ledger owner absent from apm.lock.yaml is a hard integrity failure independent of security.audit.fail_on_drift; audit exits non-zero in both default and CI modes and refuses to mutate deployed bytes while ownership is invalid" + - id: req-pl-017 + keyword: MUST + section: "6.8" + conformance_class: governance + notes: "Azure DevOps organization-policy discovery uses a distinct primary apm/apm-policy cache first and permits a distinct _apm/_apm fallback only after HTTP 404; non-404 failures do not fall back and legacy success warns" + - id: req-pl-018 + keyword: MUST + section: "6.3.1" + conformance_class: governance + notes: "allow, deny, and exact require package operands use the documented per-host repository case rule of req-rs-016 plus the registry-coordinate case rule; ASCII normalization does not cross virtual paths, refs, registry names, MCP names, unmanaged paths, or case-sensitive host/source identities; recursive-glob ambiguity remains case-sensitive and deny precedence remains unchanged" + - id: req-rs-001 + keyword: MUST + section: "7.2" + conformance_class: consumer + - id: req-rs-002 + keyword: MUST + section: "7.3" + conformance_class: consumer + - id: req-rs-003 + keyword: MUST + section: "7.3" + conformance_class: consumer + - id: req-rs-004 + keyword: MUST + section: "7.5" + conformance_class: consumer + - id: req-rs-005 + keyword: MUST + section: "7.6" + conformance_class: consumer + - id: req-rs-006 + keyword: MUST + section: "7.2" + conformance_class: consumer + - id: req-rs-007 + keyword: MUST + section: "7.3" + conformance_class: consumer + - id: req-rs-008 + keyword: MUST + section: "7.1" + conformance_class: consumer + - id: req-rs-009 + keyword: MUST + section: "7.5.1" + conformance_class: consumer + - id: req-rs-010 + keyword: MUST + section: "7.2" + conformance_class: consumer + - id: req-rs-011 + keyword: MUST + section: "7.7" + conformance_class: consumer + - id: req-rs-012 + keyword: MUST + section: "7.7" + conformance_class: consumer + - id: req-rs-013 + keyword: MUST + section: "7.2" + conformance_class: consumer + - id: req-rs-014 + keyword: MUST + section: "7.3.1" + conformance_class: consumer + - id: req-rs-015 + keyword: MUST + section: "7.5" + conformance_class: consumer + - id: req-rs-016 + keyword: MUST + section: "7.2" + conformance_class: consumer + notes: "minimum repository identity binds every cache layer; distinct identities must not share source material" + - id: req-rs-017 + keyword: MUST + section: "7.7" + conformance_class: consumer + notes: "full-SHA update extensions deterministically select the highest eligible non-prerelease annotated tag, retain pins with no eligible tag, persist git-literal provenance, and stop before writes on malformed or failed remote resolution" + - id: req-pr-001 + keyword: MUST + section: "8.2" + conformance_class: consumer + - id: req-pr-002 + keyword: MUST + section: "8.3" + conformance_class: consumer + - id: req-pr-003 + keyword: MUST + section: "8.3" + conformance_class: consumer + - id: req-pr-004 + keyword: MUST + section: "7.8" + conformance_class: producer + - id: req-pr-005 + keyword: SHOULD + section: "7.8" + conformance_class: producer + - id: req-tg-001 + keyword: MUST + section: "8.4" + conformance_class: consumer + - id: req-tg-002 + keyword: MUST + section: "8.5" + conformance_class: consumer + - id: req-tg-003 + keyword: MUST + section: "8.5" + conformance_class: consumer + - id: req-tg-004 + keyword: MUST + section: "4.2.1" + conformance_class: consumer + - id: req-tg-005 + keyword: MUST + section: "8.5" + conformance_class: consumer + - id: req-tg-006 + keyword: MUST + section: "8.5" + conformance_class: consumer + - id: req-tg-007 + keyword: MUST + section: "8.5" + conformance_class: consumer + - id: req-tg-008 + keyword: MUST + section: "8.5.3" + conformance_class: consumer + - id: req-tg-009 + keyword: MUST + section: "8.5.1" + conformance_class: consumer + - id: req-tg-010 + keyword: MUST + section: "8.5.4" + conformance_class: consumer + notes: "project-scoped native hooks use a portable project-directory environment anchor and reject shell-expansion path syntax" + - id: req-tg-011 + keyword: MUST + section: "8.5.5" + conformance_class: consumer + notes: "a schema-bearing Agent Plugins v1 dependency stays opaque to legacy projection at deployment; target exclusion may retain materialization and lock state but creates no native registration or primitive projection, remains nonfatal to ordinary mixed-batch dependencies, and is reported consistently by dry-run" + - id: req-tg-012 + keyword: MUST + section: "8.5.6" + conformance_class: consumer + notes: "implementation-defined plugin-root placeholders preserve balanced expandable double quoting across split-quote forward- or backslash path spelling and unresolved placeholders emit a default-visible diagnostic" + - id: req-tg-013 + keyword: MUST + section: "8.5.7" + conformance_class: consumer + notes: "schema, effective-target, integrity, security, and executable admission drives one in-place aggregate registration opaque to legacy projection without lifecycle host-binary probing; only admitted dependencies participate in claimant selection and registration, direct dependencies win plugin-name collisions over transitive dependencies, equal-precedence collisions fail during admission, advisory cleanup omits ambiguous or changed-owner entries, and ledger-primary ownership permits exact-entry recovery of a reserved namespace while rejecting foreign collisions and invalid JSON, preserving unrelated JSON semantics, and rolling back catalog, ledger, and settings together" + - id: req-tg-014 + keyword: MUST + section: "8.5.8" + conformance_class: consumer + notes: "user-scoped MCP target selection uses a disclosed versioned capability contract, ignores project-only detection signals, applies first-source precedence without fallback, filters mixed sets, and refuses zero-supported selections before user manifest, lockfile, or target-config mutation" + - id: req-pr-006 + keyword: MUST + section: "8.1" + conformance_class: consumer + notes: "legacy plugin skills declarations replace discovery; invalid, escaping, symlinked, and duplicate-derived entries fail closed" + - id: req-pr-007 + keyword: MUST + section: "8.1" + conformance_class: consumer + notes: "declared Plugin component copying canonicalizes its non-symlink source root and prunes the current operation's materialization subtree" + - id: req-sc-001 + keyword: MUST + section: "10.4" + conformance_class: consumer + - id: req-sc-002 + keyword: MUST + section: "10.9" + conformance_class: consumer + - id: req-sc-003 + keyword: MUST + section: "10.3" + conformance_class: consumer + - id: req-sc-004 + keyword: MUST + section: "10.5" + conformance_class: consumer + - id: req-sc-005 + keyword: MUST + section: "10.3" + conformance_class: consumer + - id: req-sc-006 + keyword: MUST + section: "4.2.3" + conformance_class: consumer + - id: req-sc-007 + keyword: MUST + section: "10.3" + conformance_class: consumer + - id: req-sc-008 + keyword: SHOULD + section: "10.3" + conformance_class: consumer + - id: req-sc-009 + keyword: MUST + section: "10.13" + conformance_class: consumer + - id: req-sc-010 + keyword: MUST + section: "10.13" + conformance_class: consumer + - id: req-sc-011 + keyword: MUST + section: "10.14" + conformance_class: consumer + - id: req-sc-012 + keyword: MUST + section: "10.14" + conformance_class: consumer + - id: req-sc-013 + keyword: MUST + section: "10.3" + conformance_class: consumer + notes: "configured authority overlap selects one documented host class before credential resolution; only that class's credentials reach requests and child processes; explicit non-default ports remain scoped" + - id: req-sc-014 + keyword: MUST + section: "10.15" + conformance_class: consumer + notes: "per-invocation consent for bin/ deployment defaults to deny when stdout is not a TTY; explicit opt-in overrides; allowExecutables policy gate evaluated first" + - id: req-sc-015 + keyword: MUST + section: "10.16" + conformance_class: consumer + oracle: tests/fixtures/spec-conformance/source-plan/req-sc-015.json + notes: "one post-authorization source-file set drives every install and uninstall re-integration scan and primitive materialization; source-only files and symlink entries are excluded" + - id: req-rg-001 + keyword: MUST + section: "11.3.3" + conformance_class: registry + - id: req-cf-001 + keyword: MUST + section: "12.5" + conformance_class: consumer + - id: req-cf-002 + keyword: MUST + section: "12.3" + conformance_class: consumer diff --git a/docs/public/specs/schemas/lockfile-v0.1.41.schema.json b/docs/public/specs/schemas/lockfile-v0.1.41.schema.json new file mode 100644 index 0000000000..356f02c546 --- /dev/null +++ b/docs/public/specs/schemas/lockfile-v0.1.41.schema.json @@ -0,0 +1,86 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://microsoft.github.io/apm/specs/schemas/lockfile-v0.1.41.schema.json", + "title": "OpenAPM Lockfile (apm.lock.yaml) v0.1, schema revision 0.1.41", + "description": "Normative JSON Schema (Draft 2020-12) for the OpenAPM v0.1 lockfile. Where this schema and the prose of OpenAPM v0.1 disagree, the prose is authoritative.", + "type": "object", + "required": ["lockfile_version", "dependencies"], + "properties": { + "lockfile_version": { "enum": ["1", "2"] }, + "generated_at": { "type": "string" }, + "apm_version": { "type": "string" }, + "dependencies": { "type": "array", "items": { "$ref": "#/$defs/entry" } }, + "mcp_servers": { "type": "array" }, + "mcp_configs": { "type": "object" }, + "local_deployed_files": { "type": "array", "items": { "type": "string" } }, + "local_deployed_file_hashes": { + "type": "object", + "additionalProperties": { + "$comment": "Bare-hex form retained for v0.1 reader-tolerance per req-lk-016; v0.2 will require the sha256: envelope exclusively.", + "type": "string", + "pattern": "^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128}|[0-9a-f]{64})$" + } + }, + "attestations": { "type": "object", "description": "Reserved for v0.2 (publisher provenance). Readers MUST preserve byte-equivalent on round-trip." } + }, + "patternProperties": { + "^x-[a-z][a-z0-9-]*$": {} + }, + "additionalProperties": true, + "$defs": { + "hashEnvelope": { + "type": "string", + "pattern": "^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128}|[0-9a-f]{64})$" + }, + "entry": { + "type": "object", + "properties": { + "repo_url": { "type": "string", "minLength": 1 }, + "alias": { + "type": "string", + "pattern": "^[a-zA-Z0-9._-]+(?![\\s\\S])", + "not": { "enum": [".", ".."] }, + "description": "Validated install-directory override, not source identity; req-mf-025." + }, + "materialization_repo_url": { + "type": "string", + "minLength": 1, + "description": "Source-cased repository identifier for materialization; non-identity metadata per req-lk-022.", + "$comment": "MUST map to the same canonical identity as repo_url under req-rs-016." + }, + "host": { "type": "string" }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "registry_prefix": { "type": "string" }, + "resolved_ref": { "type": "string" }, + "resolved_commit": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "tree_sha256": { "$ref": "#/$defs/hashEnvelope" }, + "version": { "type": "string" }, + "virtual_path": { "type": "string" }, + "is_virtual": { "type": "boolean" }, + "depth": { "type": "integer", "minimum": 0 }, + "resolved_by": { "type": "string" }, + "package_type": { "type": "string" }, + "skill_subset": { "type": "array", "items": { "type": "string" } }, + "deployed_files": { "type": "array", "items": { "type": "string" } }, + "deployed_file_hashes": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/hashEnvelope" } + }, + "source": { "enum": ["local", "registry"] }, + "resolved_url": { "type": "string" }, + "resolved_hash": { "$ref": "#/$defs/hashEnvelope" }, + "local_path": { "type": "string" }, + "content_hash": { "$ref": "#/$defs/hashEnvelope" }, + "is_dev": { "type": "boolean" }, + "constraint": { "type": "string" }, + "resolved_tag": { "type": "string" }, + "resolved_at": { "type": "string" }, + "name": { "type": "string" }, + "declared_license": { "type": "string" }, + "attestations": { "type": "object" } + }, + "patternProperties": { "^x-[a-z][a-z0-9-]*$": {} }, + "additionalProperties": true + } + } +} diff --git a/docs/public/specs/schemas/manifest-v0.1.41.schema.json b/docs/public/specs/schemas/manifest-v0.1.41.schema.json new file mode 100644 index 0000000000..046052a169 --- /dev/null +++ b/docs/public/specs/schemas/manifest-v0.1.41.schema.json @@ -0,0 +1,127 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://microsoft.github.io/apm/specs/schemas/manifest-v0.1.41.schema.json", + "title": "OpenAPM Manifest (apm.yml) v0.1, schema revision 0.1.41", + "description": "Normative JSON Schema (Draft 2020-12) for the OpenAPM v0.1 manifest. Where this schema and the prose of OpenAPM v0.1 disagree, the prose is authoritative.", + "type": "object", + "required": ["name", "version"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "version": { + "type": "string", + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$" + }, + "description": { "type": "string" }, + "author": { "type": "string" }, + "license": { "type": "string" }, + "default_host": { "type": "string", "minLength": 1 }, + "target": { + "description": "Legacy-compatible target restriction spelling; null means omitted. Mutually exclusive with targets. See req-tg-008.", + "oneOf": [ + { "type": "string", "minLength": 1 }, + { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } }, + { "type": "null" } + ] + }, + "targets": { + "description": "Canonical target restriction set or all sentinel; mutually exclusive with target. See req-tg-008.", + "oneOf": [ + { "type": "string", "minLength": 1, "pattern": "^(all|[a-z][a-z0-9-]*)$" }, + { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1, "pattern": "^(all|[a-z][a-z0-9-]*)$" } } + ] + }, + "type": { "type": "string", "enum": ["instructions", "skill", "hybrid", "prompts"] }, + "scripts": { + "type": "object", + "additionalProperties": { "type": "string" } + }, + "includes": { + "oneOf": [ + { "const": "auto" }, + { "type": "array", "items": { "type": "string" } } + ] + }, + "registries": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "required": ["url"], + "properties": { + "url": { "type": "string", "pattern": "^https?://" }, + "insecure": { "type": "boolean" }, + "aliases": { "type": "array", "items": { "type": "string" } } + }, + "patternProperties": { "^x-[a-z][a-z0-9-]*$": {} }, + "additionalProperties": false + } + ] + } + }, + "dependencies": { "$ref": "#/$defs/depsBlock" }, + "devDependencies": { "$ref": "#/$defs/depsBlock" }, + "compilation": { "type": "object" }, + "policy": { + "type": "object", + "properties": { + "hash": { "type": "string" }, + "hash_algorithm": { "enum": ["sha256", "sha384", "sha512"] } + } + }, + "marketplace": { "type": "object" } + }, + "patternProperties": { + "^x-[a-z][a-z0-9-]*$": {} + }, + "$comment": "Mutual exclusivity: target and targets MUST NOT both appear (req-tg-008).", + "not": { "required": ["target", "targets"] }, + "additionalProperties": true, + "$defs": { + "depsBlock": { + "type": "object", + "properties": { + "apm": { "type": "array", "items": { "$ref": "#/$defs/depEntry" } }, + "mcp": { "type": "array", "items": { "$ref": "#/$defs/depEntry" } }, + "conflict_resolution": { + "enum": ["intersection-pick"], + "$comment": "v0.1 admits only `intersection-pick` (the default tri-modal policy clause per sec.7.2). The token `nest` is reserved for v0.2; consumers MUST refuse v0.1 manifests declaring `nest` per req-rs-013." + } + }, + "patternProperties": { "^x-[a-z][a-z0-9-]*$": {} }, + "additionalProperties": true + }, + "depEntry": { + "oneOf": [ + { "type": "string", "minLength": 1 }, + { + "type": "object", + "properties": { + "git": { "type": "string" }, + "id": { "type": "string" }, + "registry": { "type": "string" }, + "version": { "type": "string" }, + "ref": { "type": "string" }, + "prerelease": { "type": "boolean" }, + "path": { "type": "string" }, + "alias": { + "type": "string", + "pattern": "^\\s*(?!\\.{1,2}\\s*(?![\\s\\S]))[a-zA-Z0-9._-]+\\s*(?![\\s\\S])", + "description": "Alias input is trimmed and validated per req-mf-025; source paths are independent." + }, + "skills": { "type": "array", "items": { "type": "string" } } + }, + "patternProperties": { "^x-[a-z][a-z0-9-]*$": {} }, + "additionalProperties": true, + "oneOf": [ + { "required": ["git"] }, + { "required": ["id"] }, + { "required": ["path"] }, + { "required": ["registry"] } + ] + } + ] + } + } +} diff --git a/docs/src/content/docs/consumer/manage-dependencies.md b/docs/src/content/docs/consumer/manage-dependencies.md index fa06637c1c..2ade487219 100644 --- a/docs/src/content/docs/consumer/manage-dependencies.md +++ b/docs/src/content/docs/consumer/manage-dependencies.md @@ -171,6 +171,8 @@ object form instead: Omit `path:` for whole-repo dependencies. Keeping aliases explicit reserves `@` for git usernames and future package-manager-compatible version syntax. + +For reserved aliases `.` and `..`, see [Rejected dependency aliases](../../troubleshooting/migration/#rejected-dependency-aliases). ::: For registry-sourced dependencies (internal packages on Artifactory or a custom registry), see diff --git a/docs/src/content/docs/reference/cli/cache.md b/docs/src/content/docs/reference/cli/cache.md index 07d8959f62..c3df238be3 100644 --- a/docs/src/content/docs/reference/cli/cache.md +++ b/docs/src/content/docs/reference/cli/cache.md @@ -43,6 +43,12 @@ Commands that require current state -- `apm install --update`, `apm install outdated` -- resolve upstream first. Update may reuse content for the resolved SHA; `--refresh` bypasses it. +After a current-state install or update successfully obtains a named ref's +checkout, the Git cache remembers that ref's resolved SHA. A later unpinned +install can reuse this observation instead of an older bare-repository ref. +Replaying an older lock pin does not overwrite the observation or change +another project's selected commit. + ## Subcommands ### `apm cache info` diff --git a/docs/src/content/docs/reference/cli/prune.md b/docs/src/content/docs/reference/cli/prune.md index 4b7acf218c..05ac4a5ce0 100644 --- a/docs/src/content/docs/reference/cli/prune.md +++ b/docs/src/content/docs/reference/cli/prune.md @@ -77,7 +77,7 @@ apm prune # cleans up what is no longer declared For each orphaned package, `apm prune`: -1. Removes the package directory from `apm_modules//` using a path-traversal-safe delete. +1. Removes the orphan's installed directory under `apm_modules/`, including flat [alias directories](../../lockfile-spec/#per-entry-fields), using a path-traversal-safe delete. 2. Reads `deployed_files` from the lockfile entry and deletes each deployed file or directory inside the project root. 3. Removes the entry from `apm.lock.yaml`. 4. Cleans up empty parent directories under both `apm_modules/` and the harness deploy roots. diff --git a/docs/src/content/docs/reference/lockfile-spec.md b/docs/src/content/docs/reference/lockfile-spec.md index 08da217069..87a3267aaf 100644 --- a/docs/src/content/docs/reference/lockfile-spec.md +++ b/docs/src/content/docs/reference/lockfile-spec.md @@ -5,7 +5,7 @@ sidebar: order: 4 --- -> **Normative reference:** this page documents the v0.2 working-draft lockfile format as emitted by the current CLI. The normative, ratified contract for v0.1 is defined in [OpenAPM v0.1, Section 5 (Lockfile)](/apm/specs/openapm-v01/) and published as JSON Schema at [`lockfile-v0.1.schema.json`](/apm/specs/schemas/lockfile-v0.1.schema.json). +> **Normative reference:** This page describes the CLI's v0.2 working draft. [OpenAPM v0.1, Section 5](../../specs/openapm-v01/#5-lockfile-format-apmlockyaml) defines the normative contract; its unratified 0.1.41 amendment selects [`lockfile-v0.1.41.schema.json`](/apm/specs/schemas/lockfile-v0.1.41.schema.json). See [schema identity and status](../../specs/openapm-v01/#appendix-a-normative-json-schemas-inline). `apm.lock.yaml` is the pinned record of every resolved dependency and every file APM deployed into the workspace. It is the source of truth for @@ -195,6 +195,7 @@ Each item in `dependencies` describes one resolved package. |---|---|---|---| | `repo_url` | string | yes | Canonical repository path or URL. Entry identity is derived from `repo_url`, `host`, and virtual/local markers; see [lockfile identity keys](#lockfile-identity-keys). | | `materialization_repo_url` | string | no | Source-cased path that preserves repository display spelling when APM reconstructs the dependency, including for `apm_modules/` materialization and generated links. Omitted when it equals `repo_url`; it must normalize to the same identity and cannot redirect a lock entry. | +| `alias` | string | no | Validated directory name directly below `apm_modules/`, preserved for replay, audit, and removal. Placement metadata only; it does not change repository identity, source coordinates, or the resolved revision. Missing means no alias override, never an alias inferred from `name`. | | `host` | string | no | FQDN when not inferable from `repo_url` (e.g. for registry proxies or non-GitHub hosts). | | `host_type` | string | no | Explicit host-kind hint, currently `gitlab`, copied from object-form `type: gitlab`. | | `port` | int | no | Non-standard SSH/HTTPS port. Validated to `1..65535` on read. | diff --git a/docs/src/content/docs/reference/manifest-schema.md b/docs/src/content/docs/reference/manifest-schema.md index 856f5c5f95..bca15b0236 100644 --- a/docs/src/content/docs/reference/manifest-schema.md +++ b/docs/src/content/docs/reference/manifest-schema.md @@ -5,7 +5,7 @@ sidebar: order: 1 --- -> **Normative reference:** this page documents the v0.3 working-draft manifest schema as implemented by the current CLI. The normative, ratified contract for v0.1 is defined in [OpenAPM v0.1, Section 4 (Manifest)](/apm/specs/openapm-v01/) and published as JSON Schema at [`manifest-v0.1.schema.json`](/apm/specs/schemas/manifest-v0.1.schema.json). +> **Normative reference:** This page describes the CLI's v0.3 working draft. [OpenAPM v0.1, Section 4](../../specs/openapm-v01/#4-manifest-format-apmyml) defines the normative contract; its unratified 0.1.41 amendment selects [`manifest-v0.1.41.schema.json`](/apm/specs/schemas/manifest-v0.1.41.schema.json). See [schema identity and status](../../specs/openapm-v01/#appendix-a-normative-json-schemas-inline).
Version
0.3 (Working Draft)
@@ -69,13 +69,13 @@ marketplace: # OPTIONAL; marketplace authoring Two fields are REQUIRED at parse time: `name` and `version`. All other fields are OPTIONAL. Unknown top-level keys MUST be preserved by writers but MAY be ignored by resolvers. -The standard `$schema` key negotiates the manifest contract. Omit it for the -current APM working draft. Set it to -`https://microsoft.github.io/apm/specs/schemas/manifest-v0.1.schema.json` for -the normative OpenAPM v0.1 shape. Unknown schema identities fail closed; APM -does not interpret a working-draft manifest as v0.1. Under explicit v0.1, -`registries` follows the normative string-or-object registry map rather than -the working draft's named map plus `default` selector. +The standard `$schema` key selects the manifest contract; omission selects +APM's current working draft. Amendment 0.1.41 selects +`https://microsoft.github.io/apm/specs/schemas/manifest-v0.1.41.schema.json` +for the alias-aware OpenAPM v0.1 shape. Unknown identities fail closed, not +back to the working draft. Explicit OpenAPM v0.1 uses the normative +string-or-object `registries` map, not the working draft's named map plus +`default` selector. The `marketplace:` block is the source for `apm pack`'s marketplace output. Repositories that do not publish a marketplace omit it entirely. See [Section 7](#7-marketplace-authoring-block). @@ -434,7 +434,7 @@ REQUIRED when the shorthand is ambiguous (e.g. direct nested-group repos with vi | `git` | `string` | REQUIRED (remote) | HTTPS URL, SSH URL, or FQDN shorthand | Clone URL of the repository. Required for remote dependencies. | | `path` | `string` | OPTIONAL / REQUIRED (local) | Relative path within the repo, or local filesystem path | When `git` is present: subdirectory or file (virtual package). When `git` is absent: local filesystem path (must start with `./`, `../`, `/`, or `~/`). | | `ref` | `string` | OPTIONAL | Branch, tag, or commit SHA | Git reference to checkout. | -| `alias` | `string` | OPTIONAL | `^[a-zA-Z0-9._-]+$` | Local alias. | +| `alias` | `string` | OPTIONAL | `^[a-zA-Z0-9._-]+$`; excludes exactly `.` and `..` | Names the destination directory, not the source; local `../` paths remain supported. Valid: `.safe`, `safe.`, `foo..bar`, `my-skill.v2`. [Migration](../../troubleshooting/migration/#rejected-dependency-aliases). | | `type` | `string` | OPTIONAL (remote Git only) | `gitlab` | Treat a bespoke hostname as self-managed GitLab. | | `allow_insecure` | `boolean` | OPTIONAL (remote Git only) | `true` or `false` | Manifest-side approval for an `http://` dependency; the install command still requires its separate insecure-host opt-in. | | `skills` | `list` | OPTIONAL | Non-empty skill names or `["*"]` | Installs only the selected skills from a dependency that exposes selectable skills. | diff --git a/docs/src/content/docs/specs/openapm-v0.1.md b/docs/src/content/docs/specs/openapm-v0.1.md index 94dab29d33..e40d407a79 100644 --- a/docs/src/content/docs/specs/openapm-v0.1.md +++ b/docs/src/content/docs/specs/openapm-v0.1.md @@ -136,7 +136,7 @@ between the companion corpus and the implementation. ### 1.3 Document conventions -- OpenAPM v0.1 carries **122 normative statements (117 MUST, 5 SHOULD)** indexed in +- OpenAPM v0.1 carries **123 normative statements (118 MUST, 5 SHOULD)** 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 @@ -544,9 +544,40 @@ and MUST NOT use both on the same entry. | `version`| yes (registry form) | Opaque version selector; semver range when registry publishes semver. | | `ref` | no | Branch, tag, semver range, or commit SHA (git form). | | `path` | no / yes (local form) | Subpath within repo, or local filesystem path. | -| `alias` | no | Local alias. | +| `alias` | no | Local install-directory override; see [req-mf-025](#req-mf-025). | | `skills` | no | Skill-subset selection for dependencies that expose selectable skills (see [Section 8.1](#81-primitive-types)). | + +**[req-mf-025]** A conforming **consumer** implementation that supports +dependency aliases MUST validate a supplied alias, after trimming surrounding +whitespace, as a non-empty string containing only ASCII letters, digits, +periods, underscores, and hyphens, excluding the reserved names `.` and `..`. +It MUST reject an invalid alias with a diagnostic identifying the alias. +Names such as `.safe`, `safe.`, `foo..bar`, and `my-skill.v2` remain valid. +The alias selects a directory directly below `apm_modules`; before using +that destination, the consumer MUST reject a path that resolves, including +through symlinks, to `apm_modules` itself or outside it. + +An alias changes placement, not repository identity, source path, or revision. +The validated, trimmed token, preserving ASCII case and periods, is the +canonical alias used for placement and lock emission. Trimming is permitted +canonicalization under [req-cf-001](#req-cf-001); subsequent round trips preserve +the canonical token. The lock-entry schema describes canonical writer output; +readers trim input before validation. +Consumers MUST preserve the resolved dependency reference's canonical alias in its lock entry and +restore it for subsequent materialization, replay, and removal. Lock readers +MUST apply the same alias validation. An absent alias retains the existing +unaliased layout; consumers MUST NOT infer an alias from package inventory +such as `name`. Local source references, including `../sibling`, retain +their declaring source anchor independently of alias placement. This +requirement does not expand source-path permissions; repository-relative +references in remote packages remain bounded by the authenticated clone root. + +Alias support follows the optional-feature disclosure in +[Section 11.2](#112-how-to-claim-conformance). Older readers may preserve +the unknown `alias` field without implementing its placement semantics; +field preservation alone does not establish alias lifecycle support. + **[req-mf-011]** A conforming **consumer** implementation MUST reject any object-form entry that sets both `id:` and `git:` on the same @@ -790,6 +821,7 @@ This section's normative statements are: [req-mf-019](#req-mf-019), [req-mf-020](#req-mf-020), [req-mf-021](#req-mf-021), [req-mf-022](#req-mf-022), [req-mf-023](#req-mf-023), [req-mf-024](#req-mf-024), + [req-mf-025](#req-mf-025), [req-ext-001](#req-ext-001), [req-ext-002](#req-ext-002), [req-tg-004](#req-tg-004), [req-sc-006](#req-sc-006). @@ -848,6 +880,7 @@ unknown fields on round-trip. Field availability is **monotonic** in |---------------------------|---------------------------------------------------------------------------------| | `repo_url` | Canonical repo identity. REQUIRED for git-sourced entries. Cache isolation additionally follows [req-rs-016](#req-rs-016). | | `materialization_repo_url` | Optional source-cased repository identifier following the same host/owner/repo-path grammar as `repo_url`, used to reconstruct materialization and generated-link paths. See [req-lk-022](#req-lk-022). | +| `alias` | Optional validated install-directory override, independent of source identity and inventory `name`. See [req-mf-025](#req-mf-025). | | `host` | FQDN when not inferable from `repo_url`. | | `port` | Non-standard port. Validated to `1..65535` on read. | | `registry_prefix` | Path prefix when resolved via registry proxy. | @@ -3155,6 +3188,9 @@ to its registered deploy root(s). The self-entry isolation in [Section 5.3](#53-self-entry-semantics) prevents the cleanup logic of one dependency from claiming the project's own files. Orphan detection MUST scope per-dependency, not globally. +For supported dependency aliases, [req-mf-025](#req-mf-025) constrains +destinations and records canonical placement for later cleanup. Containment +alone does not establish ownership or authorize deletion of existing content. ### 10.8 Policy bypass via crafted manifest @@ -3219,7 +3255,7 @@ every stored hash, foreclosing algorithm-ambiguity attacks. | 4 | Lockfile tampering | [req-lk-012](#req-lk-012), [req-lk-013](#req-lk-013), [req-lk-016](#req-lk-016), [req-lk-017](#req-lk-017), [req-sc-001](#req-sc-001) | Consumer-default | | 5 | Registry impersonation | [req-lk-013](#req-lk-013), [req-rs-009](#req-rs-009), [req-sc-004](#req-sc-004); v0.2 TLS-only deferred | Consumer-default | | 6 | Malicious package execution at install time | No install-time execution path; [req-pl-006](#req-pl-006) defence | Consumer-default | -| 7 | Unverified content cleanup | [req-tg-002](#req-tg-002), [req-lk-020](#req-lk-020), [req-lk-021](#req-lk-021); self-entry isolation | Consumer-default | +| 7 | Unverified content cleanup | [req-tg-002](#req-tg-002), [req-lk-020](#req-lk-020), [req-lk-021](#req-lk-021); [req-mf-025](#req-mf-025) for supported aliases; self-entry isolation | Consumer-default | | 8 | Policy bypass via crafted manifest | [req-pl-002](#req-pl-002), [req-pl-009](#req-pl-009), [req-pl-010](#req-pl-010), [req-pl-018](#req-pl-018) | Governance-only | | 9 | Archive path-traversal | [req-sc-002](#req-sc-002), [req-sc-004](#req-sc-004) | Consumer-default | | 10| Hash-algorithm downgrade | [req-mf-018](#req-mf-018), [req-lk-016](#req-lk-016) | Consumer-default | @@ -3460,6 +3496,7 @@ conformance statement identifying: [req-mf-019](#req-mf-019), [req-mf-020](#req-mf-020), [req-mf-021](#req-mf-021), [req-mf-022](#req-mf-022), [req-mf-023](#req-mf-023), [req-mf-024](#req-mf-024), +[req-mf-025](#req-mf-025), [req-ext-001](#req-ext-001), [req-lk-001](#req-lk-001), [req-lk-002](#req-lk-002), [req-lk-003](#req-lk-003), [req-lk-004](#req-lk-004), @@ -3773,31 +3810,40 @@ provided under the published docs site: | `https://microsoft.github.io/apm/spec/latest` | Newest ratified version | Human citation in prose. Toolchains MUST NOT pin to `latest`; pin to a versioned URL. | | `https://microsoft.github.io/apm/spec` | Alias of `latest` | Short prose citation. Same restriction as `latest` -- do not pin tooling. | -The JSON Schemas published alongside this specification (Appendix -A) are themselves identified by the `$id` URL embedded in each -schema. Toolchains MUST pin to the `$id` URL verbatim; the schema -files are byte-immortal at those URLs for the lifetime of this -version. +Each schema's embedded `$id` identifies it. Toolchains MUST pin to that +URL verbatim; schema files are byte-immortal there for this version's +lifetime. The published [`manifest-v0.1.schema.json`](/apm/specs/schemas/manifest-v0.1.schema.json) and +[`lockfile-v0.1.schema.json`](/apm/specs/schemas/lockfile-v0.1.schema.json) URLs and bytes remain unchanged and available; +[Appendix A](#appendix-a-normative-json-schemas-inline) selects this +amendment's distinct resources. ## Appendix A. Normative JSON Schemas (inline) -The machine-readable schemas backing this specification are -published alongside this document and are normative. +The schemas below define this specification's normative structural contract. + +:::note[Planned] +Amendment 0.1.41 selects new, independent manifest and lockfile resources +with distinct `$id` URLs. Human approval and the public comment period under +[Section 9.3](#93-amendment-process) remain pending; listing these URLs does +not establish approval or publication. +::: | Schema | Authoritative source (in-tree) | |-----------------------|------------------------------------------------------------------------------------------------------| -| Manifest (`apm.yml`) | [`schemas/manifest-v0.1.schema.json`](/apm/specs/schemas/manifest-v0.1.schema.json) (JSON Schema 2020-12). | -| Lockfile (`apm.lock.yaml`) | [`schemas/lockfile-v0.1.schema.json`](/apm/specs/schemas/lockfile-v0.1.schema.json) (JSON Schema 2020-12). | +| Manifest (`apm.yml`) | [`schemas/manifest-v0.1.41.schema.json`](/apm/specs/schemas/manifest-v0.1.41.schema.json) (JSON Schema 2020-12). | +| Lockfile (`apm.lock.yaml`) | [`schemas/lockfile-v0.1.41.schema.json`](/apm/specs/schemas/lockfile-v0.1.41.schema.json) (JSON Schema 2020-12). | | Policy (`apm-policy.yml`) | [`schemas/policy-v0.1.schema.json`](/apm/specs/schemas/policy-v0.1.schema.json) (JSON Schema 2020-12). | | Claude-Code marketplace (informational, emitted output) | `tests/fixtures/schemas/claude-code-marketplace.schema.json` | | Claude-Code plugin (informational, emitted output) | `tests/fixtures/schemas/claude-code-plugin.schema.json` | -The reference Python validator `src/apm_cli/policy/schema.py` -remains in-tree as a **non-normative cross-reference** for -implementers; the JSON Schema is authoritative. Schemas for -manifest and lockfile validation are JSON-Schema-only in v0.1; a -reference Python validator MAY be added in a future minor revision -without normative effect. +Consumers checking the structural constraints for [req-mf-025](#req-mf-025) +choose the distinct 0.1.41 `$id` URLs above. Validation against the older +structural schemas does not waive newer prose or runtime requirements. + +`src/apm_cli/policy/schema.py` is a **non-normative cross-reference**; +the policy JSON Schema is authoritative. Manifest and lockfile validation +are JSON-Schema-only in v0.1; a reference Python validator MAY be added +in a future minor revision without normative effect. Where a JSON Schema and the prose of this specification disagree, the **prose** is authoritative and the schema is treated as an @@ -3862,6 +3908,7 @@ renumbering of conformance classes. | [req-mf-022](#req-mf-022) | MUST | 4.3.2 | consumer | | [req-mf-023](#req-mf-023) | MUST | 4.5 | consumer | | [req-mf-024](#req-mf-024) | MUST | 4.3.2 | consumer | +| [req-mf-025](#req-mf-025) | MUST | 4.3.2 | consumer | | [req-ext-001](#req-ext-001) | MUST | 4.1 | consumer | | [req-ext-002](#req-ext-002) | MUST | 4.1 | producer | | [req-lk-001](#req-lk-001) | MUST | 5.1 | consumer | @@ -3961,7 +4008,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: 122** (117 MUST, 5 SHOULD). +**Total normative statements: 123** (118 MUST, 5 SHOULD). --- @@ -4011,6 +4058,7 @@ renumbering of conformance classes. | 0.1.38 | 2026-09-01 | Defensive amendment of [req-lk-005] (no new normative statement; count remains 120 (115 MUST, 5 SHOULD)): `generated_at` is optional advisory metadata, new lockfiles omit it by default, and later writes preserve an existing omission unless explicitly configured otherwise. | | 0.1.39 | 2026-09-01 | Spec-citation fold for user-scoped direct MCP target selection (closes #2548 Mode-B silent-extension gate). Added [req-tg-014] (Section 8.5.8, consumer MUST): explicit selection, the user-scope manifest, configured user default, and user-scope runtime discovery form one precedence chain; project-only signals cannot constrain final discovery; and a selected set with no user-capable runtime fails before user manifest, lockfile, or target-config mutation. Section 8.7, Section 11.3.2, and Appendix C updated. Statement count: 120 -> 121 (116 MUST, 5 SHOULD). | | 0.1.40 | 2026-09-07 | Spec-citation fold for dependency-policy identity casing in PR #2706. Added [req-pl-018] (Section 6.3.1, governance MUST) and extended [req-rs-016] clause (3): dependency allow, deny, and exact require operands use the documented per-host repository case rule, while registry-sourced repository coordinates are case-insensitive regardless of host; case normalization is ASCII-only, is bounded identically on both operands, stops at recursive-glob ambiguity, and does not cross virtual-path, ref, registry-name, MCP-name, unmanaged-path, or case-sensitive host/source boundaries; deny precedence is unchanged. Defined the policy glob grammar, documented byte-exact Section 6.4 merge behavior, and added the threat mapping. Classified this as a non-breaking correction of previously unspecified evaluation behavior under Section 9.2: existing lowercase workarounds remain matching; on registry sources and hosts documented as case-insensitive, case-variant allow entries can newly match, deny entries can newly enforce, and exact require entries can newly be satisfied, so those policies should be re-audited. Sections 1.3, 6.3.1, 6.3.5, 6.4, 6.5, 6.9, 7.2, 9.2, 10.8, 10.11, 11.2, and 11.3.4, Appendix C, and conformance coverage updated. Statement count: 121 -> 122 (117 MUST, 5 SHOULD). | +| 0.1.41 | 2026-09-09 | Alias containment and lock-replay contract for PR #2901. Added [req-mf-025] (Section 4.3.2, consumer MUST), the optional lock-entry `alias` field, and conformance coverage. Under Section 9.2 this is an additive optional field and a defensive definition of previously unspecified alias behavior, not behavior-neutral errata: unsafe or reserved aliases can newly fail; valid dotted aliases remain accepted; surrounding whitespace is canonicalized; recorded aliases determine replay placement; absent aliases retain the unaliased layout. Source identity and permitted local source paths are unchanged. Older readers preserving the unknown field do not thereby implement placement support. Selects distinct 0.1.41 schema publication identities without changing published v0.1 URLs or bytes; Section 9.3 remains pending (see Appendix A). Sections 1.3, 4.9, 5.2, 11.3.2, and Appendix C updated. Statement count: 122 -> 123 (118 MUST, 5 SHOULD). | Errata (none at publication). diff --git a/docs/src/content/docs/specs/openapm-v0.2.md b/docs/src/content/docs/specs/openapm-v0.2.md new file mode 100644 index 0000000000..383650ccfe --- /dev/null +++ b/docs/src/content/docs/specs/openapm-v0.2.md @@ -0,0 +1,4458 @@ +--- +title: OpenAPM v0.2.0 (inactive editor's draft) +description: Inactive editor's draft of OpenAPM v0.2.0; v0.1 remains active. +slug: specs/openapm-v020 +sidebar: + order: 1 +--- + +OpenAPM v0.2.0 is the corrective specification of the APM package format, manifest, lockfile, and policy semantics. It is the contract implementers, conformance testers, and enterprise reviewers build against. If you are learning how to USE APM, start with the consumer, producer, or enterprise guides -- this page defines what APM IS, not how to operate it. + +## Status of This Document + +This document is an **editor's Working Draft** of OpenAPM, exact revision +**v0.2.0**. It is the **inactive corrective-spec foundation** retained in +[microsoft/apm#2820](https://github.com/microsoft/apm/pull/2820), not an +implementation-conformance statement or an active replacement for v0.1. +It inherits alias safety and independently versioned schemas prospectively from +[microsoft/apm#2901](https://github.com/microsoft/apm/pull/2901) at exact commit +[`5a0d872b9dd63b78b08923eddfad0ed1f9b369b4`](https://github.com/microsoft/apm/commit/5a0d872b9dd63b78b08923eddfad0ed1f9b369b4), +not a published or ratified baseline. +[microsoft/apm#2919](https://github.com/microsoft/apm/pull/2919) owns the coupled +local-source and audit executable assessment and bindings against this exact +draft; [microsoft/apm#2923](https://github.com/microsoft/apm/pull/2923) owns the +aggregate. This foundation selects no assessment and activates no runtime +changes. **v0.1 remains active.** Human review and ratification remain pending; +no announcement, publication, or ratification date is recorded here. + +Beyond that prospective dependency, this corrective minor changes only +local-source provenance and anchoring ([req-mf-016](#req-mf-016)) and the +current-intent, read-only audit contract ([req-lk-023](#req-lk-023)) in +Section 5.5. Previously reserved features remain inactive. + +OpenAPM is published under the **MIT License**. + +Version **v0.2.0** is a `0.x` editor's draft under semantic-version-zero +discipline: no backward-compatibility guarantee applies until version +**1.0**. Each `0.x` minor MAY introduce breaking changes with the +migration window described in [Section 9](#9-versioning-and-amendment-process). + +Editors track feedback in `microsoft/apm` and amend per the process in +[Section 9.3](#93-amendment-process). + +## Abstract + +OpenAPM defines the on-disk file formats, dependency-resolution semantics, +primitive type system, deployment matrix, and governance policy format used +by the Agent Package Manager (APM). A conforming producer authors an +`apm.yml` manifest; a conforming consumer resolves the declared +dependencies, writes an `apm.lock.yaml` lockfile, and deploys primitives +to the directories the targets matrix specifies; a conforming governance +implementation evaluates an `apm-policy.yml` policy against the install +plan before any byte is written to disk. The wire contract between +consumers and registry servers is **not normative in this revision** and is +reserved for a future revision (see [Appendix B](#appendix-b-registry-http-api-reserved)). + +## Table of Contents + +1. [Introduction](#1-introduction) +2. [Conventions](#2-conventions) +3. [Terminology](#3-terminology) +4. [Manifest format (apm.yml)](#4-manifest-format-apmyml) +5. [Lockfile format (apm.lock.yaml)](#5-lockfile-format-apmlockyaml) +6. [Policy format (apm-policy.yml)](#6-policy-format-apm-policyyml) +7. [Dependency resolution](#7-dependency-resolution) +8. [Primitive type system and target matrix](#8-primitive-type-system-and-target-matrix) +9. [Versioning and amendment process](#9-versioning-and-amendment-process) +10. [Security considerations](#10-security-considerations) +11. [Conformance](#11-conformance) +12. [Conformance test methodology](#12-conformance-test-methodology) +13. [Appendix A: Normative JSON Schemas (inline)](#appendix-a-normative-json-schemas-inline) +14. [Appendix B: Registry HTTP API (reserved)](#appendix-b-registry-http-api-reserved) +15. [Appendix C: Index of normative statements](#appendix-c-index-of-normative-statements) +16. [Appendix D: Revision history](#appendix-d-revision-history) +17. [Appendix E: Editorial reconciliation notes](#appendix-e-editorial-reconciliation-notes) + +--- + +## 1. Introduction + +### 1.1 Goals and non-goals + +**Goals.** + +- Define an interoperable on-disk format (`apm.yml`, `apm.lock.yaml`, + `apm-policy.yml`) that any conformant tool can read and write. +- Specify dependency resolution semantics precisely enough that two + independent implementations produce equivalent lockfiles from the + same manifest and remote state. +- Specify a target-matrix contract (detection signals and deploy + directories) so consumers can pin deploy paths against the spec + rather than against a single implementation. +- Specify a governance policy format that lets organisations gate + installs without forking the consumer toolchain. + +**Non-goals (this revision).** + +- The registry HTTP wire contract. The companion document + [registry-http-api.md](../../reference/registry-http-api/) is + **informational** in this revision and is reserved for normative inclusion + in a future revision once independent server implementations exist. +- The on-disk format of any third-party plugin distribution channel + (such as the Claude-Code plugin marketplace). The OPTIONAL + `marketplace:` block in the manifest is normative as **input**; + the generated `marketplace.json` artifact is governed externally + and tracked additively. +- The runtime API of any harness or IDE. OpenAPM describes what is + written to disk and where; it does not describe what a harness + reads from that disk. +- Account, billing, identity, or audit-log semantics for hosted + registries. +- Publisher identity, signature verification, and attestation + envelopes remain out of scope and reserved for a future revision. + See [Section 10.12](#1012-publisher-provenance-and-attestations-reserved). +- Reproducible-build determinism of registry archives + (mtime/uid/gid normalisation, tar member ordering beyond + filesystem-natural order) remains out of scope and + reserved for a future revision. +- Version withdrawal (yank, deprecate, supersede) for published + versions remains out of scope and reserved for a future revision. + See [Section 7.9](#79-version-withdrawal-reserved). +- Workspace / monorepo composition (shared lockfile across sibling + packages, intra-workspace resolve-to-local, workspace publish) is + out of scope and reserved for a future revision; current + monorepo usage is supported via local-path dependencies per + [Section 4.3.5](#435-local-path-dependencies). See + [Section 4.8](#48-workspaces-reserved). +- The consumer-side integrity model supplies checks against + a non-conforming registry (hash-verify-before-extract, + re-verify-on-frozen); residual gaps (availability, + enforcement of version-immutability, publisher identity) remain outside + the consumer guarantee in this revision. The existing Registry + trust-anchor obligation [req-rg-001](#req-rg-001) remains operative. + +### 1.2 Relationship to existing APM reference documentation + +The reference pages under `docs/src/content/docs/reference/` are +**non-normative companions** to this specification: + +| Companion page | Role | +|--------------------------------------------------------------------------------------|-----------------------------------| +| [`manifest-schema.md`](../../reference/manifest-schema/) | Field reference and examples for [Section 4](#4-manifest-format-apmyml). | +| [`lockfile-spec.md`](../../reference/lockfile-spec/) | Lifecycle table and examples for [Section 5](#5-lockfile-format-apmlockyaml). | +| [`policy-schema.md`](../../reference/policy-schema/) | Field reference and examples for [Section 6](#6-policy-format-apm-policyyml). | +| [`primitive-types.md`](../../reference/primitive-types/) | Conceptual model for [Section 8](#8-primitive-type-system-and-target-matrix). | +| [`package-types.md`](../../reference/package-types/) | Producer-side layout decision tree for [Section 8](#8-primitive-type-system-and-target-matrix). | +| [`targets-matrix.md`](../../reference/targets-matrix/) | Per-target support matrix; informational supplement to [Section 8](#8-primitive-type-system-and-target-matrix). | +| [`registry-http-api.md`](../../reference/registry-http-api/) | Informational only; normative wire surface remains reserved. | + +Where a companion describes behaviour, this specification binds it. +Where a companion and this specification disagree, **this specification +wins**. Editorial notes in this document call out reconciliations +between the companion corpus and the implementation. + +### 1.3 Document conventions + +- This revision carries **124 normative statements (119 MUST, 5 SHOULD)** 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 + [req-mf-020](#req-mf-020). A complete YAML safe-subset profile + (anchor/alias handling, tag whitelist, octal-coercion treatment) + is reserved for a future revision, not activated by v0.2.0. +- Field names are `snake_case` unless they mirror an external + contract (such as `tagPattern` in the OPTIONAL marketplace block). +- All examples in this document are ASCII-only. Implementations + encountering non-ASCII bytes in OpenAPM files MUST treat them as + UTF-8 and either preserve them on round-trip or reject them at + parse time with a diagnostic. Unicode normalisation (NFC), IDNA + for hosts, bidi-safe rendering, and locale-of-diagnostics are + out of scope and reserved for a future revision (see + [Section 1.4](#14-terminology-preliminaries)). +- "Implementation" means any program that produces, consumes, or + evaluates an OpenAPM file. A given implementation MAY claim more + than one conformance class. + +### 1.4 Terminology preliminaries + +This revision distinguishes two host-related concepts that earlier +drafts conflated: + +- **Implementation-default host** -- the host an implementation uses + when the manifest omits `default_host:` (see + [Section 4.2.4](#424-default_host)). This is an implementation + choice, not a spec mandate; the reference APM CLI's + implementation-default host is `github.com`. +- **Wire-format host** -- the host literal that appears in a + dependency identifier or lockfile entry. Canonical normalisation + (see [req-mf-009](#req-mf-009)) strips the project's + `default_host:` only. + +**Internationalization considerations (reserved).** This revision +does not normatively specify IDNA normalisation for hosts, Unicode +normalisation form (NFC) for package or owner names, bidi-safe +rendering of identifiers, or diagnostic locale. These remain reserved +for a future revision. Implementations SHOULD compare names byte-for-byte +after the canonical normalisation defined in +[req-mf-009](#req-mf-009). + +--- + +## 2. Conventions + +The key words "**MUST**", "**MUST NOT**", "**REQUIRED**", "**SHALL**", +"**SHALL NOT**", "**SHOULD**", "**SHOULD NOT**", "**RECOMMENDED**", +"**MAY**", and "**OPTIONAL**" in this document are to be interpreted +as described in BCP 14 +([RFC 2119](https://datatracker.ietf.org/doc/html/rfc2119), +[RFC 8174](https://datatracker.ietf.org/doc/html/rfc8174)) when, and +only when, they appear in all capitals. + +Lowercase variants of these words ("must", "should", and so on) carry +no normative weight and are descriptive prose. + +Every normative statement in this document carries a stable identifier +of the form `req--` (for example `req-mf-001`) anchored +directly above the statement. These identifiers are stable across +errata; a renumbering requires a minor version bump as defined in +[Section 9.2](#92-breaking-vs-non-breaking-change-definition). + +Conformance classes are defined normatively in +[Section 11.1](#111-conformance-classes-normative); the four roles are +**Producer**, **Consumer**, **Registry** (one MUST applies in +this revision; the wire contract remains reserved), and **Governance**. +An implementation MUST declare which conformance class(es) it +claims when asserting OpenAPM conformance (see +[Section 11.2](#112-how-to-claim-conformance)). + +--- + +## 3. Terminology + +This section defines the terms used throughout this document. Where a +term has a separate normative definition (for example "primitive +type"), the definition section is cross-linked. + +| Term | Definition | +|---|---| +| **Manifest** | The `apm.yml` file for one package or installation scope. Defined in [Section 4](#4-manifest-format-apmyml). | +| **Lockfile** | The `apm.lock.yaml` file recording one installation scope's resolved state. Defined in [Section 5](#5-lockfile-format-apmlockyaml). | +| **Installation scope** | The isolated manifest, lockfile, and target-configuration boundary for an install. A project scope is rooted at the consumer project. A user scope is independent of any project root and uses an implementation-defined user location disclosed by the consumer's conformance statement. | +| **Source anchor** | The original declaring package's source directory used to resolve a relative dependency path. It is not the dependency's staging or deployment directory. See [Section 4.3.5](#435-local-path-dependencies). | +| **Source containment root** | The boundary within which source content is permitted to resolve: the authenticated repository root for a remote-declared relative dependency, or the selected local package's resolved source directory for symlinks inside that package. It is distinct from installation scope and from the source anchor. | +| **Policy** | An `apm-policy.yml` file evaluated by a Governance implementation. Defined in [Section 6](#6-policy-format-apm-policyyml). | +| **Package** | A unit identified by a manifest (`apm.yml`) or by a recognised package layout (see [Section 8.1](#81-primitive-types)). | +| **Primitive** | A typed unit of agent configuration (instruction, prompt, agent, skill, command, hook, or mcp server). Defined in [Section 8.1](#81-primitive-types). | +| **Target** | A named runtime harness (for example `copilot`, `claude`, `cursor`). Defined in [Section 8.4](#84-target-detection-signals-normative). | +| **Deploy directory** | The on-disk root under which a target's primitives are placed by `apm install`. Defined in [Section 8.5](#85-deploy-directory-contract-normative). | +| **Source-declared capability restriction** | An agent-primitive field whose documented purpose is to narrow the tools, actions, or resources the converted agent may invoke (for example, a `tools` allowlist). | +| **Direct dependency** | A dependency declared in the consumer's own `apm.yml`. | +| **Transitive dependency** | A dependency declared in the `apm.yml` of a resolved package, not in the consumer's own `apm.yml`. | +| **Virtual package** | A dependency targeting a subdirectory or file within a repository rather than the whole repository. Defined in [Section 4.3.3](#433-virtual-packages). | +| **Registry** | A remote service that serves package archives over HTTP. The wire contract remains reserved for a future revision. | +| **git-semver** | A dependency form whose `ref:` is a semver range matched against remote git tags. Defined in [Section 7.3](#73-git-semver-resolution). | +| **Constraint** | The version selector recorded for a dependency (a semver range, a literal tag, a branch name, a commit SHA, or `None`). | +| **Drift** | A divergence between the lockfile and either the manifest (declaration drift) or the deployed files on disk (integrity drift). | +| **Self-entry** | The synthesized lockfile entry that accounts for primitives the project itself contributes. Defined in [Section 5.3](#53-self-entry-semantics). | +| **Frozen install** | An install operation that refuses to write or rewrite the lockfile, fails on any missing package pin or MCP declaration mismatch, and gates validation before any lockfile, target-configuration, deployment, or cache write. Defined in [Section 5.5](#55-drift-and-integrity-model). | +| **Host class** | The equivalence set of network hosts that share a single credential scope (see [Section 10.3](#103-token-leakage-across-hosts)). Two hosts are in the same class **iff** their registrable domain (the eTLD+1 per the Public Suffix List) is identical, OR they are explicitly aliased via `registries..aliases:` (see [Section 4.2.3](#423-registries)). For example, `github.contoso.com` shares a host class with `contoso.com`, not with `github.com`. Implementation-specific operator overrides to this default assignment are governed by [req-sc-013](#req-sc-013). | +| **Configuration signal** | Any manifest declaration or implementation-specific operator setting that binds a hostname to a host class. Defined in [req-sc-013](#req-sc-013). | +| **Implementation-default host** | The host an implementation uses when the manifest omits `default_host:`. The choice is implementation-defined; see [Section 1.4](#14-terminology-preliminaries). | +| **Wire-format host** | The host literal as it appears in a dependency identifier or lockfile entry after canonical normalisation. | +| **Hash envelope** | A digest serialised as `:` (for example `sha256:abcd...`). See [req-lk-016](#req-lk-016). | +| **Conformance class** | One of the four roles defined in [Section 11.1](#111-conformance-classes-normative). | +| **Conformant** | Satisfies all MUST-level requirements for a claimed conformance class. | +| **Conforming file** | An OpenAPM file that parses without error under the rules of [Sections 4](#4-manifest-format-apmyml)-[6](#6-policy-format-apm-policyyml). | + +--- + +## 4. Manifest format (apm.yml) + +### 4.1 Document structure and required fields + +The project-scope manifest is a single YAML 1.2 document located at the +project root, filename `apm.yml`. A user-scope manifest, when supported, +uses the user location declared under [req-tg-014](#req-tg-014). + + +**[req-mf-001]** A conforming **producer** implementation MUST emit a +manifest whose top-level document node is a YAML 1.2 mapping. A +conforming **consumer** implementation MUST reject any manifest whose +top-level node is not a mapping, with a diagnostic naming the file. + + +**[req-mf-002]** A conforming **producer** implementation MUST include +a top-level field `name` whose value is a non-empty string. + + +**[req-mf-003]** A conforming **producer** implementation MUST include +a top-level field `version` whose value is a string. + + +**[req-mf-004]** A conforming **producer** implementation SHOULD emit +a `version` value matching the official semver 2.0.0 reference +regular expression (semver 2.0.0 Section 9): + +``` +^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$ +``` + +A conforming **consumer** SHOULD emit a non-blocking diagnostic +when `version` does not match this pattern. Numeric-looking version +strings MUST be quoted in YAML to prevent integer/float coercion. + + +**[req-mf-006]** A conforming **consumer** implementation MUST +preserve unknown top-level keys when rewriting the manifest, so that +manifests authored against a later revision of this specification +round-trip through an older consumer without data loss. + + +**[req-mf-020]** A conforming **consumer** implementation MUST parse +manifest, lockfile, and policy documents under the YAML safe +subset: (a) scalars are strings unless explicitly typed via the +canonical YAML 1.2 tags `!!int`, `!!float`, or `!!bool`; (b) +`&anchor` / `*alias` constructs MUST be rejected with a diagnostic; +(c) custom (non-`!!`) tags MUST be rejected; (d) YAML 1.1 octal +coercion (`0NN` interpreted as base-8) MUST NOT be applied. The +complete safe-subset profile is reserved for a future revision; implementations +MUST at minimum enforce clauses (a)-(d). + + +**[req-ext-001]** A conforming **consumer** implementation MUST +treat any mapping key matching the pattern `x-[a-z][a-z0-9-]*` at +any nesting level of a manifest, lockfile, or policy document as a +**vendor-extension key**. Vendor-extension keys MUST be ignored +during semantic interpretation, MUST NOT cause parse-time errors, +and MUST be preserved byte-equivalent on round-trip. The same rule +applies to vendor-extension keys inside `dependencies.apm[]`, +`dependencies.mcp[]`, lockfile per-entry mappings, and policy +sub-blocks. See [Section 4.6.2](#462-vendor-extensions). + + +**[req-ext-002]** This specification and all future revisions of +OpenAPM MUST NOT define normative keys beginning with the prefix +`x-`. The namespace is reserved exclusively for vendor extensions. + +A minimal conforming manifest: + +```yaml +name: my-project +version: 1.0.0 +``` + +### 4.2 Field reference + +The manifest top-level fields are: + +| Field | Required | Type | +|-----------------|----------|-----------------------------------------------------------------------------------------------| +| `name` | yes | string | +| `version` | yes | string (semver 2.0.0 per [req-mf-004](#req-mf-004)) | +| `description` | no | string | +| `author` | no | string | +| `license` | no | string (SPDX identifier RECOMMENDED) | +| `default_host` | no | string; see [Section 4.2.4](#424-default_host) and [req-mf-019](#req-mf-019) | +| `target` | no | string, list of strings, or null; mutually exclusive with `targets` (see [req-tg-008](#req-tg-008)) | +| `targets` | no | string or non-empty list of lowercase canonical identifiers or `all`; mutually exclusive with `target` (see [req-tg-008](#req-tg-008)) | +| `type` | no | string (advisory, see [Section 4.2.2](#422-type-advisory)) | +| `scripts` | no | mapping `string -> string` | +| `includes` | no | literal `auto` or list of paths | +| `registries` | no | mapping; see [Section 4.2.3](#423-registries) | +| `dependencies` | no | mapping with OPTIONAL keys `apm`, `mcp`, `conflict_resolution` | +| `devDependencies` | no | mapping with OPTIONAL keys `apm`, `mcp` | +| `compilation` | no | mapping | +| `policy` | no | mapping; consumer-side policy hooks | +| `marketplace` | no | mapping; producer authoring block, see [Section 4.7](#47-marketplace-authoring-block-normative-input) | +| `x-` | no | vendor extension, see [Section 4.6.2](#462-vendor-extensions) | + +#### 4.2.1 `target` + +The canonical set of `target` identifiers registered by this +specification in this revision is: + +``` +copilot, claude, cursor, codex, gemini, antigravity, opencode, windsurf, agent-skills, all +``` + +The legacy aliases `vscode` and `agents` MAY appear in input manifests +and MUST be normalised to `copilot` when the manifest is rewritten. +The internal fallback value `minimal` MUST NOT be set explicitly in a +manifest; it is reserved for the auto-detection fallback described in +[Section 8.4](#84-target-detection-signals-normative). In an +auto-detect context, `minimal` denotes the no-target-detected profile +that emits `AGENTS.md` only; `all` denotes the union of every +registered **auto-detectable** target (see +[Section 8.4](#84-target-detection-signals-normative)). A target is +**auto-detectable** when the OpenAPM Target Registry publishes at +least one detection predicate for it; a target registered without a +detection predicate is **explicit-only** and MUST be selected +explicitly. In this revision the explicit-only targets are `agent-skills` and +`antigravity`, so `all` excludes them. + +Concrete per-target detection signals and deploy roots are documented +in the non-normative companion **"OpenAPM Target Registry v0.1"** +(see [Section 8](#8-primitive-type-system-and-target-matrix)); the +companion holds the table contents and is amended additively; this +specification binds the schema only. + + +**[req-mf-005]** A conforming **producer** implementation MUST reject +any value of `target` (or any element of a `target` list) that is not +either: (a) a member of the canonical set or a recognised alias; OR +(b) a vendor-extension identifier matching +`x-[a-z][a-z0-9-]*-[a-z][a-z0-9-]*` (the +`x--` form). The diagnostic MUST name the offending +token. + + +**[req-tg-004]** A conforming **consumer** implementation MUST accept +target identifiers matching `x-[a-z][a-z0-9-]*-[a-z][a-z0-9-]*` at +parse time and MUST route detection, deployment, and conformance +evaluation for such identifiers to a vendor-registered handler. In +the absence of a registered handler, the consumer MUST emit a +diagnostic naming the unsupported identifier and MUST NOT silently +ignore the entry. Vendors MAY register new targets without spec +amendment via this namespace. + +> **Editorial note.** Editorial reconciliation between the canonical +> set above and the legacy aliases is consolidated in +> [Appendix E](#appendix-e-editorial-reconciliation-notes). + +#### 4.2.2 `type` (advisory) + +The `type` field MAY take one of the values `instructions`, `skill`, +`hybrid`, or `prompts`. Its semantic content is **advisory** in this revision: +package behaviour is driven by the on-disk layout recognised in +[Section 8.1](#81-primitive-types), not by this field. Future +revisions MAY assign behavioural meaning; conformant consumers MUST +NOT reject a manifest solely on the basis of the `type` field's value +when that value is one of the four listed above. Editorial +reconciliation with the companion is in +[Appendix E](#appendix-e-editorial-reconciliation-notes). + +#### 4.2.3 `registries` + +The `registries` block MAY declare REST-based registries the project +consumes. The block is OPTIONAL; absence of the block means +git-resolution-only. + + +**[req-mf-014]** A conforming **producer** implementation MUST ensure +that every `registries..url` value begins with `https://` or +`http://`. Other URL schemes MUST be rejected at parse time. + + +**[req-mf-015]** A conforming **producer** implementation MUST reject +unknown keys inside any `registries.` entry at parse time, +**except** for vendor-extension keys matching `x-[a-z][a-z0-9-]*` +(see [req-ext-001](#req-ext-001)). This constraint is a typo guard +and prevents silent acceptance of mistyped keys. + + +**[req-sc-006]** A conforming **consumer** implementation MUST treat +any `registries..url` that uses the `http://` scheme as a +parse-time error **unless** one of the following is true: (a) the +entry sets `insecure: true` explicitly; OR (b) the host is the +loopback address (`127.0.0.0/8`, `::1`) or an RFC 1918 private +address (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`). The +diagnostic MUST name the offending registry. + +Optional fields on a `registries.` mapping: + +| Field | Type | Notes | +|------------|-----------|-----------------------------------------------------------------------| +| `url` | string | REQUIRED; scheme constrained by [req-mf-014](#req-mf-014). | +| `insecure` | boolean | When `true`, allows `http://`. See [req-sc-006](#req-sc-006). | +| `aliases` | string[] | Additional host names that share this registry's host class. See [Section 10.3](#103-token-leakage-across-hosts). | + +Example: + +```yaml +registries: + internal: + url: https://artifactory.example.com/artifactory/api/skills/internal + aliases: + - mirror.example.com + default: internal +``` + +#### 4.2.4 `default_host` + +The OPTIONAL `default_host:` top-level field selects the host that +canonical normalisation strips from shorthand dependency identifiers +(see [Section 4.3.4](#434-canonical-normalisation-writer-requirements)). +When omitted, the consumer uses its implementation-default host; +this specification does not mandate `github.com` or any other value +for the implementation-default. + + +**[req-mf-019]** A conforming **consumer** implementation that +encounters a `default_host:` value MUST treat that value as the only +host stripped by canonical normalisation per +[req-mf-009](#req-mf-009). When the manifest omits `default_host:`, +the consumer MAY apply its implementation-default host but MUST +document that choice in its conformance statement (see +[Section 11.2](#112-how-to-claim-conformance)). A consumer MUST NOT +strip any host other than the one selected by `default_host:` or the +declared implementation-default. + +### 4.3 Dependencies block (apm + mcp) + +The OPTIONAL `dependencies` block has two OPTIONAL list-valued keys: +`apm` for agent primitive packages, and `mcp` for MCP servers. +Implementations MAY encounter unknown sibling keys (such as future +dependency kinds) and MUST preserve them on rewrite per +[req-mf-006](#req-mf-006). + +#### 4.3.1 String form + +Each `dependencies.apm` entry MAY be a string conforming to the +following grammar (RFC 5234 ABNF): + +``` +dependency = url-form / shorthand-form / local-path-form + +url-form = url-scheme clone-url +url-scheme = "https://" / "http://" / "ssh://git@" / "git@" +clone-url = host [ ":" port ] "/" owner "/" repo + [ "/" virtual-path ] [ "#" ref ] + +shorthand-form = [ host "/" ] owner "/" repo + [ "/" virtual-path ] [ "#" ref ] + +local-path-form = local-prefix path-tail +local-prefix = "./" / "../" / "/" / "~/" / ".\" / "..\" / "~\" +path-tail = 1*pchar + +host = 1*( ALPHA / DIGIT / "-" / "." ) +port = 1*DIGIT ; range 1-65535 +owner = 1*( ALPHA / DIGIT / "-" / "_" ) +repo = 1*( ALPHA / DIGIT / "-" / "_" / "." ) +virtual-path = segment *( "/" segment ) +segment = 1*( ALPHA / DIGIT / "-" / "_" / "." ) +ref = 1*VCHAR +pchar = ALPHA / DIGIT / "/" / "\" / ":" / "." / "-" / "_" / "~" +``` + +`clone-url` MAY include a `:port` segment on `https://`, `http://`, +and `ssh://git@` forms. `local-path-form` paths beginning with a +backslash-prefixed `local-prefix` are normalised to POSIX form on +read; see [Section 4.3.4](#434-canonical-normalisation-writer-requirements). + +ABNF productions are interpreted per RFC 5234. `ALPHA`, `DIGIT`, and +`VCHAR` are the core rules from RFC 5234 Appendix B.1. + + +**[req-mf-007]** A conforming **consumer** implementation MUST parse +string-form `dependencies.apm` entries per the grammar above. +Implementations MUST reject any string that does not satisfy one of +the three productions, with a diagnostic identifying the offending +entry. + +#### 4.3.2 Object form + +An object-form entry uses one of two identity keys, `git:` or `id:`, +and MUST NOT use both on the same entry. + +| Field | Required | Notes | +|----------|-----------------------------------------|-----------------------------------------------------------------------| +| `git` | yes for git-sourced; mutually excl. `id`| Clone URL or shorthand. Special value `parent` defined below. | +| `id` | yes for registry-sourced; mutually excl. `git` | `/` registry identity. | +| `registry` | no | Registry name; defaults to project default if omitted. | +| `version`| yes (registry form) | Opaque version selector; semver range when registry publishes semver. | +| `ref` | no | Branch, tag, semver range, or commit SHA (git form). | +| `path` | no / yes (local form) | Subpath within repo, or local filesystem path. | +| `alias` | no | Local install-directory override; see [req-mf-025](#req-mf-025). | +| `skills` | no | Skill-subset selection for dependencies that expose selectable skills (see [Section 8.1](#81-primitive-types)). | + + +**[req-mf-025]** A conforming **consumer** implementation that supports +dependency aliases MUST validate a supplied alias, after trimming surrounding +whitespace, as a non-empty string containing only ASCII letters, digits, +periods, underscores, and hyphens, excluding the reserved names `.` and `..`. +It MUST reject an invalid alias with a diagnostic identifying the alias. +Names such as `.safe`, `safe.`, `foo..bar`, and `my-skill.v2` remain valid. +The alias selects a directory directly below `apm_modules`; before using +that destination, the consumer MUST reject a path that resolves, including +through symlinks, to `apm_modules` itself or outside it. + +An alias changes placement, not repository identity, source path, or revision. +The validated, trimmed token, preserving ASCII case and periods, is the +canonical alias used for placement and lock emission. Trimming is permitted +canonicalization under [req-cf-001](#req-cf-001); subsequent round trips preserve +the canonical token. The lock-entry schema describes canonical writer output; +readers trim input before validation. +Consumers MUST preserve the resolved dependency reference's canonical alias in its lock entry and +restore it for subsequent materialization, replay, and removal. Lock readers +MUST apply the same alias validation. An absent alias retains the existing +unaliased layout; consumers MUST NOT infer an alias from package inventory +such as `name`. Local source references, including `../sibling`, retain +their declaring source anchor independently of alias placement. This +requirement does not expand source-path permissions; repository-relative +references in remote packages remain bounded by the authenticated clone root. + +Alias support follows the optional-feature disclosure in +[Section 11.2](#112-how-to-claim-conformance). Older readers may preserve +the unknown `alias` field without implementing its placement semantics; +field preservation alone does not establish alias lifecycle support. + + +**[req-mf-011]** A conforming **consumer** implementation MUST reject +any object-form entry that sets both `id:` and `git:` on the same +entry. The diagnostic MUST name the entry and the conflicting keys. + + +**[req-mf-022]** A conforming **consumer** implementation that applies +a non-empty `skills:` subset to a dependency that exposes selectable +skills (see [Section 8.1](#81-primitive-types)) and deploys zero skills +from that dependency because no selected name matches an available +skill MUST emit a default-visible diagnostic before the install +operation returns. The diagnostic MUST identify the dependency, the +requested skill names, and the available skill names (or state that +none are available). The consumer MAY complete the overall install +successfully when no other error exists; this requirement does not turn +a stale persisted subset into an install failure. + + +**[req-mf-010]** A conforming **consumer** implementation MUST treat +the literal sentinel `git: parent` as valid **only** inside a +transitively resolved package whose clone coordinates are known to +the resolver. The resolver MUST expand `parent` to the parent +package's `host`, `repo_url`, and resolved `ref`, with `virtual_path` +taken from `path`. The literal `parent` MUST NOT appear in the +lockfile as durable identity (`repo_url` or `source`). + + +**[req-mf-024]** A conforming **consumer** implementation MUST NOT +silently rewrite an existing `id:`-form (registry-sourced) manifest +entry into a `git:`-form entry when persisting a subsequent CLI-driven +manifest update (e.g. an additive `--skill` pin) for the same +dependency identity. When a CLI-parsed reference is ambiguous about +its source (git vs. registry) but an existing manifest entry for the +same identity already resolves to the `registry` source, the +implementation MUST honor the existing entry's source when +serializing the updated entry. If an update would otherwise replace a +registry-sourced entry with a non-registry-shaped entry, the +implementation MUST reject the update with a diagnostic naming the +identity, rather than silently converting it. + +#### 4.3.3 Virtual packages + +A dependency MAY target a subdirectory or a file within a repository +rather than the whole repository. + + +**[req-mf-008]** A conforming **consumer** implementation MUST +classify virtual packages by **file extension only** and MUST NOT +infer kind from path segments. A `virtual_path` ending in +`.prompt.md`, `.instructions.md`, or `.agent.md` is a +file; any other path is a subdirectory. On-disk shape of a +subdirectory virtual package is resolved by probing for `apm.yml` +first. + +#### 4.3.4 Canonical normalisation (writer requirements) + + +**[req-mf-009]** A conforming **consumer** implementation MUST +normalise dependency entries to canonical form when rewriting the +manifest. The canonical form strips **only** the host that matches +the project's `default_host:` value (per +[req-mf-019](#req-mf-019)) or, if `default_host:` is omitted, the +consumer's declared implementation-default host. SCP-style git +URLs (`git@host:owner/repo.git`) and `https://` URLs targeting the +selected default host MUST be normalised to the shorthand form +`owner/repo`. Non-default hosts MUST retain their FQDN. A consumer +MUST NOT hard-code stripping of any specific host literal; the +selection is configured per project. + +#### 4.3.5 Local-path dependencies + + +**[req-mf-016]** A conforming **consumer** implementation MUST +recognise dependency strings beginning with `./`, `../`, `/`, `~/`, +`.\`, `..\`, or `~\` as local-path entries. Admission and resolution +depend on the declaring source, not merely on the presence of `..`. +This prefix recognition is syntactic, not authorization to read an +operator-local filesystem path. For remote-declared entries, apply +clause (c) before operator-local admission under clause (b); a +successfully derived Git reference is not an operator-local source: + +(a) **Selected local sources.** A consumer MAY admit operator-selected +local sources at project and user scope, including absolute paths, +home-expanded paths, and sibling packages outside the consumer +project root. A consumer MAY restrict allowed local source roots +through operator configuration or a documented implementation policy; +this requirement does not mandate access to every local root. Such +restrictions MUST be reported when they cause rejection, not presented +as a successfully materialized dependency. + +An admitted direct project-scope relative dependency MUST resolve +from the consumer project's source directory. An admitted absolute +path MUST be resolved as an absolute source, after any home-directory +expansion. An admitted relative dependency declared by an explicitly +selected local package, or by another local package reached through +that declared local dependency chain, MUST resolve from the declaring +package's original source directory, including at user scope. The +consumer MUST NOT substitute a staging directory, deployment directory, +or unrelated current working directory for the declaring source anchor. + +(b) **Operator-local user-scope admission.** A direct relative local +filesystem dependency at user scope MUST be rejected. A relative +transitive local filesystem dependency +at user scope MUST be rejected unless the consumer has established +its declaring local parent and that parent's original absolute +source directory. Provenance comes from the declaring dependency's +established source kind, not its repository name or path spelling. +A repository-name prefix such as `_local/`, a local-looking path, +or a recorded path string alone is not proof that its parent is local. +Unknown provenance does not authorize an operator-local read. +The consumer MUST NOT +search another installation scope's installed packages to supply a +missing source anchor. + +(c) **Remote-declared paths.** A relative local-path entry declared +by a remote Git package MUST resolve inside the authenticated parent +repository root, after path normalisation and symlink resolution. +An admitted entry MUST retain the parent's remote repository identity +and ref, and MUST NOT become a read from the consumer's local package +namespace. Absolute paths, including home-expanded and Windows +absolute paths, and paths escaping that repository MUST be rejected. +If the entry cannot be tied to that remote repository and ref, it +MUST be rejected rather than treated as a trusted local dependency. + +(d) **Local package contents.** Once a local source directory is +selected and resolved, that directory is the containment root for +symlinks inside the package. An internal symlink whose resolved +target stays inside that root MUST be materialized as the target's +content, subject to the other applicable admission and content +selection rules. A broken or cyclic internal symlink, or one whose +target escapes that root, MUST cause local materialization to fail. +Choosing a path that resolves to a source directory is distinct from +dereferencing symlinks within the selected package's content. + +Rejections under this requirement MUST identify the offending path +and the reason for refusal. These source rules do not change the +manifest, lockfile, or target-configuration boundary of the selected +installation scope. + +**Security note (informative).** Selecting a local source is an +explicit trust decision about that source and its declared local +dependency chain, not a claim that local content is harmless. +Source anchoring, remote-repository containment, internal-symlink +containment, and deployment eligibility are separate checks. This +requirement does not promise atomicity of the entire install or +race-free filesystem isolation against concurrent source mutation. +An `alias` is destination naming, not source authorization or a substitute +source anchor. It does not relax destination containment, including the +registered target roots under [req-tg-002](#req-tg-002). + +**Local replay interoperability (informative).** A relative spelling +alone does not identify a local source. Replaying a local dependency +chain requires reestablishing the original declaring-source context; +a recorded path string is not authorization to read it. An absolute +local path does not imply that the same source exists or is approved +on another machine. The reference CLI's `declaring_parent` and +`anchored_local_path` metadata are implementation-specific; this +requirement does not standardize those fields or a portable local +lockfile representation. + +#### 4.3.6 MCP dependencies + +The OPTIONAL `dependencies.mcp` list declares MCP servers. Each entry +is either a registry string or an object. Object-form fields are +defined in [`manifest-schema.md` Section 4.2](../../reference/manifest-schema/#42-dependenciesmcp----listmcpdependency). + + +**[req-mf-012]** A conforming **consumer** implementation MUST reject +any self-defined MCP server entry (one where `registry: false`) that: +(a) omits `transport`; (b) sets `transport: stdio` but omits +`command`; (c) sets `transport` to `http`, `sse`, or `streamable-http` +but omits `url`. When `transport: stdio` is in effect, the `command` +value MUST be a single binary path with no embedded whitespace +**unless** the entry also supplies an `args` key (including an +explicit empty list); a path containing spaces without an `args` +sibling MUST be rejected at parse time. + +### 4.4 devDependencies + +The OPTIONAL `devDependencies` block has the same structure as +`dependencies`. Entries declared under `devDependencies` are +installed locally but excluded from packed plugin bundles produced +by the producer toolchain. + +### 4.5 Variable references in MCP env/headers and runtime arguments + +Values inside `mcp[].env` and `mcp[].headers` MAY contain three +placeholder syntaxes: + +| Syntax | Source | Resolution | +|-------------------|-----------------------|-------------------------------------------------------------------------| +| `${VAR}` | host environment | Normalised to `${env:VAR}` for native interpolation; resolved at install for others. | +| `${env:VAR}` | host environment | Passed through where natively supported; resolved at install otherwise. | +| `${input:}` | interactive prompt | Native where supported; otherwise the placeholder MUST NOT be silently rendered as literal text. | + +GitHub Actions templates (`${{ ... }}`) MUST be left untouched. + + +**[req-mf-013]** A conforming **consumer** implementation MUST +resolve `${VAR}`, `${env:VAR}`, and `${input:}` placeholders per +the dispatch matrix above and MUST NOT emit a generated config file +in which an unsupported placeholder is silently passed through as +literal text. When an unsupported placeholder is encountered for the +active target, the consumer MUST emit a diagnostic and MAY refuse to +write the generated config. + +Registry OCI/Docker package `runtime_arguments` and `package_arguments` +entries MAY contain bare `{name}` templates in their `value` or `default` +fields. An entry's `variables` map declares metadata for variable names +across the package; `isSecret: true` marks a name secret. This syntax is +distinct from the `${...}` env/header forms above. + + +**[req-mf-023]** A conforming **consumer** implementation that renders +a registry OCI/Docker MCP package to VS Code configuration MUST apply a +resolved non-secret variable value to every `{name}` occurrence across +the package's runtime and package arguments, including an occurrence +whose argument does not repeat the variable metadata. Secret +classification is package-scoped: once any entry declares a name with +`isSecret: true`, the consumer MUST use the VS Code secret input +reference for every occurrence of that name rather than write the +resolved secret value into generated configuration bytes. The consumer +MUST NOT write a literal unresolved `{name}` template to generated VS +Code configuration; when a required runtime-argument variable cannot +be resolved, it MUST emit a diagnostic and MAY decline that package's +target configuration. + +### 4.6 Manifest extension surfaces + +#### 4.6.1 `policy` (consumer-side controls) + +The OPTIONAL `policy` block records consumer-side controls for +governance integration (such as a pinned hash of the leaf +`apm-policy.yml`). Its sub-fields are documented in the companion +page and are enforced normatively in [Section 6](#6-policy-format-apm-policyyml). +The manifest `policy:` block pins the **discovered** policy bytes +by hash, defending against MITM or registry-side rewrite between +discovery and evaluation; see [Section 6.1](#61-loading-and-discovery). + + +**[req-mf-018]** A conforming **consumer** implementation MUST accept +only `sha256`, `sha384`, or `sha512` as the value of +`policy.hash_algorithm`. Values of `md5`, `sha1`, or any other +algorithm MUST be rejected at parse time. When `policy.hash` is set +and `policy.hash_algorithm` is omitted, the algorithm MUST be +inferred from the `:` prefix of the digest value. The +algorithm allow-list applies to the digest of the discovered policy +bytes, not to a recursive hash of the manifest field itself. + +#### 4.6.2 Vendor extensions + +OpenAPM reserves the key prefix `x-` (lowercase ASCII) for +vendor-defined extensions at every mapping level of every document +this specification defines: manifest top-level, every nested +mapping within `dependencies`, `devDependencies`, `registries`, +`policy`, `compilation`, `marketplace`; lockfile top-level and +per-entry mappings (see [Section 5.2](#52-per-entry-fields)); and +policy top-level and every nested mapping (see +[Section 6](#6-policy-format-apm-policyyml)). + +Key shape is `x-[a-z][a-z0-9-]*`. Vendors SHOULD further namespace +their keys as `x--` (for example +`x-acme-telemetry`) to avoid collision between independent vendors. + +[req-ext-001](#req-ext-001) and [req-ext-002](#req-ext-002) govern +reader and writer behaviour for this namespace. Round-trip +preservation is the load-bearing guarantee: a manifest authored +with `x-acme-telemetry:` MUST survive a `read -> mutate -> write` +cycle through any conforming consumer byte-equivalent, even if the +consumer ascribes no semantics to the key. + +### 4.7 Marketplace authoring block (normative input) + +The OPTIONAL `marketplace` block declares the producer's marketplace +authoring metadata. The on-disk shape of the `marketplace.json` +artifact that a producer toolchain emits from this block is +**outside the scope** of this specification; the input format defined +here is normative. + + +**[req-mf-017]** A conforming **producer** implementation MUST +validate every `marketplace.packages[].source` value against the +following rules and MUST reject any entry that fails them at parse +time: (a) `..` path segments are refused; (b) URL forms with +userinfo (`user@host`), ports, or query strings are refused; +(c) URL schemes other than `https://` are refused for remote +sources; (d) local sources MUST begin with `./`. + +### 4.8 Workspaces (reserved) + +Workspace / monorepo composition (shared lockfile across sibling +packages, intra-workspace resolve-to-local, workspace publish) is +**out of scope and reserved for a future revision; not activated by +v0.2.0**. Local-path dependencies per +[Section 4.3.5](#435-local-path-dependencies) do not imply workspace +support. A future design may define a top-level `workspaces:` glob list, +a shared root lockfile, and intra-workspace local resolution. + + +**[req-mf-021]** In this revision, a conforming **producer** MUST NOT +declare a top-level `workspaces:` key in `apm.yml`. A conforming +**consumer** encountering a top-level `workspaces:` key in a +manifest MUST emit a non-blocking diagnostic naming the key as +reserved for a future revision and MUST NOT attach any semantics to its value. +The diagnostic MUST NOT fail install. + +### 4.9 Conformance requirements (manifest) + +This section's normative statements are: + +- Producer: [req-mf-001](#req-mf-001), [req-mf-002](#req-mf-002), + [req-mf-003](#req-mf-003), [req-mf-005](#req-mf-005), + [req-mf-014](#req-mf-014), [req-mf-015](#req-mf-015), + [req-mf-017](#req-mf-017), [req-mf-021](#req-mf-021), + [req-ext-002](#req-ext-002). +- Producer (SHOULD): [req-mf-004](#req-mf-004). +- Consumer: [req-mf-001](#req-mf-001), + [req-mf-006](#req-mf-006), [req-mf-007](#req-mf-007), + [req-mf-008](#req-mf-008), [req-mf-009](#req-mf-009), + [req-mf-010](#req-mf-010), [req-mf-011](#req-mf-011), + [req-mf-012](#req-mf-012), [req-mf-013](#req-mf-013), + [req-mf-016](#req-mf-016), [req-mf-018](#req-mf-018), + [req-mf-019](#req-mf-019), [req-mf-020](#req-mf-020), + [req-mf-021](#req-mf-021), [req-mf-022](#req-mf-022), + [req-mf-023](#req-mf-023), [req-mf-024](#req-mf-024), + [req-mf-025](#req-mf-025), + [req-ext-001](#req-ext-001), + [req-tg-004](#req-tg-004), [req-sc-006](#req-sc-006). +- Consumer (SHOULD): [req-mf-004](#req-mf-004), limited to its existing + Consumer version-validation diagnostic clause. + +--- + +## 5. Lockfile format (apm.lock.yaml) + +### 5.1 Top-level structure + +The project-scope lockfile is a single YAML 1.2 document at the project +root, filename `apm.lock.yaml`. A user-scope lockfile, when supported, +uses the user location declared under [req-tg-014](#req-tg-014). A +lockfile records the pinned resolved state of every dependency the +consumer has resolved from the manifest, plus the set of files the +consumer itself contributes (the self-entry). + + +**[req-lk-001]** A conforming **consumer** implementation MUST emit a +lockfile whose top-level document node is a YAML 1.2 mapping with at +minimum the keys `lockfile_version` (string) and `dependencies` +(list). Additional top-level keys defined by this specification are +`generated_at`, `apm_version`, `mcp_servers`, `mcp_configs`, +`local_deployed_files`, `local_deployed_file_hashes`, and +`attestations` (the last remains reserved per +[Section 10.12](#1012-publisher-provenance-and-attestations-reserved)). +Vendor-extension top-level keys (`x-*`) are permitted per +[req-ext-001](#req-ext-001). + +Example (informative, minimal): + +```yaml +lockfile_version: "1" +apm_version: "0.6.4" +dependencies: + - repo_url: github.com/octocat/example + resolved_commit: "7f3c9a4d2e1b8c7f0a9e6d5c4b3a2918f7e6d5c4" + resolved_ref: v1.2.0 + tree_sha256: "sha256:a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00" + depth: 1 + deployed_files: + - .github/instructions/example.instructions.md +``` + +### 5.2 Per-entry fields + +Each element of `dependencies` describes one resolved package. The +following fields are recognised by this specification; producers and +consumers MUST emit only fields whose values are set and MUST preserve +unknown fields on round-trip. Field availability is **monotonic** in +`lockfile_version`: a field defined here is valid in both `"1"` and +`"2"`. The `"v2 only"` annotation used in earlier drafts is removed +(see [req-lk-002](#req-lk-002)). + +| Field | Notes | +|---------------------------|---------------------------------------------------------------------------------| +| `repo_url` | Canonical repo identity. REQUIRED for git-sourced entries. Cache isolation additionally follows [req-rs-016](#req-rs-016). | +| `materialization_repo_url` | Optional source-cased repository identifier following the same host/owner/repo-path grammar as `repo_url`, used to reconstruct materialization and generated-link paths. See [req-lk-022](#req-lk-022). | +| `alias` | Optional validated install-directory override, independent of source identity and inventory `name`. See [req-mf-025](#req-mf-025). | +| `host` | FQDN when not inferable from `repo_url`. | +| `port` | Non-standard port. Validated to `1..65535` on read. | +| `registry_prefix` | Path prefix when resolved via registry proxy. | +| `resolved_ref` | User-supplied ref (branch, tag, SHA). | +| `resolved_commit` | Exact 40-character lowercase hexadecimal commit SHA-1. | +| `tree_sha256` | Hash envelope (`sha256:`) over the canonicalised git tree. See [req-lk-015](#req-lk-015). | +| `version` | Registry entries carry the registry-resolved selector for reinstall; git/local entries MAY carry a dependency-`apm.yml`-derived inventory value per [req-lk-019](#req-lk-019). | +| `virtual_path` | Subpath inside repo for virtual packages. | +| `is_virtual` | Boolean. | +| `depth` | Tree depth (0 = self, 1 = direct, >1 = transitive). | +| `resolved_by` | `repo_url` of the parent that pulled this transitive dep. | +| `package_type` | One of `apm_package`, `skill_bundle`, etc. | +| `skill_subset` | Selected skill names for dependencies that expose selectable skills (see [Section 8.1](#81-primitive-types)). | +| `deployed_files` | Project-relative paths the consumer wrote for this entry. | +| `deployed_file_hashes` | `path -> :` for the files in `deployed_files`. | +| `source` | `local` for path deps, `registry` for registry deps; absent for git. | +| `resolved_url` | Registry archive download URL (advisory; see [req-rs-009](#req-rs-009)). | +| `resolved_hash` | Hash envelope (`sha256:`) of the registry archive bytes. Trust anchor. | +| `local_path` | Original path for local deps. | +| `content_hash` | Hash envelope (`sha256:`) of a local package's source tree. | +| `is_dev` | True when declared under `devDependencies`. | +| `constraint` | git-semver discriminator: the original semver range from the manifest (verbatim). | +| `resolved_tag` | git-semver selected tag, or advisory tag provenance for a full-SHA git-literal update under [req-rs-017](#req-rs-017). | +| `resolved_at` | git-semver: ISO 8601 UTC timestamp; advisory (see [Section 7.3](#73-git-semver-resolution)). | +| `name` | Self-asserted display/inventory name; non-identity (see [req-lk-019](#req-lk-019)). | +| `attestations` | Reserved for a future revision (publisher provenance). | +| `x-` | Vendor extension (per [req-ext-001](#req-ext-001)). | + + +**[req-lk-003]** A conforming **consumer** implementation MUST record +both `repo_url` and `resolved_commit` for every git-sourced +dependency entry. For every registry-sourced dependency entry the +consumer MUST instead record `resolved_url` and `resolved_hash` +(in addition to `repo_url`, which carries package identity). +When the manifest reference is a full 40-character commit SHA, a +conformance audit MUST treat a different lockfile `resolved_commit` as +a consistency failure. + + +**[req-lk-011]** A conforming **consumer** implementation MUST omit +fields whose values are unset (no `null` placeholders) and MUST +preserve fields it does not recognise when round-tripping a lockfile. +This includes vendor-extension keys per [req-ext-001](#req-ext-001). + + +**[req-lk-012]** A conforming **consumer** implementation MUST +compute `deployed_file_hashes` and `local_deployed_file_hashes` as +SHA-256 hash envelopes (`sha256:`) over the *canonical +content* of each deployed file. The canonical content is defined as +follows: if the file's bytes decode as UTF-8 and contain no NUL +(`0x00`) byte (a *text* file), the canonical content is those bytes +with every `\r\n` sequence replaced by `\n` (a lone `\r` is left +unchanged); otherwise (a *binary* file) the canonical content is the +raw bytes as written to disk. Directory entries (paths ending in `/`) +MUST NOT have a hash entry. The hash envelope `:` form +defined by [req-lk-016](#req-lk-016) applies uniformly. + +The text-file line-ending normalization makes the recorded hash +*platform-invariant*: a file that git materializes with `\r\n` on a +`core.autocrlf=true` checkout and with `\n` on a POSIX checkout yields +one identical hash, so the record side (install) and the verify side +([req-lk-017](#req-lk-017), `apm audit`) agree across operating +systems (apm#1952). Preserving a lone `\r` is deliberate: only the +benign `\r\n` -> `\n` platform difference is made hash-invisible, +while a bare carriage return -- which a terminal or parser may treat +as an overwrite -- still changes the hash. This domain matches the +drift-replay normalizer, so the two integrity surfaces agree on what +constitutes a content change. + + +**[req-lk-013]** A conforming **consumer** implementation MUST verify +the `resolved_hash` against the actual SHA-256 of the registry +archive bytes **before** extracting the archive to disk. On +mismatch, the install MUST fail closed with a diagnostic naming the +entry, the expected hash, and the actual hash, and MUST NOT extract +or partially extract the archive. + + +**[req-lk-014]** A conforming **consumer** implementation MUST +preserve vendor-extension keys (`x-[a-z][a-z0-9-]*`) at every +mapping level of the lockfile -- top-level and per-entry -- on +round-trip. See [req-ext-001](#req-ext-001). + + +**[req-lk-019]** A conforming **consumer** implementation MUST treat +the optional `name` field, and any dependency-`apm.yml`-derived +`version` value, as **self-asserted inventory metadata** only -- +recorded to support human-readable listing and audit reporting, +never as a trust anchor. A consumer MUST preserve both fields on +round-trip per [req-lk-011](#req-lk-011), and MUST NOT derive any +identity or deduplication decision from them. For git/local entries, +a dependency-`apm.yml`-derived `version` MUST NOT drive frozen +replay; replay derives from `resolved_ref`, `resolved_commit`, +`resolved_tag`/`constraint`, and the recorded hash envelopes (see +[req-lk-003](#req-lk-003), [req-lk-008](#req-lk-008)). For registry +entries, the registry-resolved `version` MAY remain the exact +registry selection used for reinstall, but the integrity anchor is +the recorded `resolved_hash` required by [req-lk-013](#req-lk-013). +The presence of `name` or dependency-`apm.yml`-derived `version` is +additive and MUST NOT change `lockfile_version` (both are valid in +`"1"` and `"2"`). + + +**[req-lk-022]** A conforming **consumer** implementation that +case-folds any component of a repository identifier (authority or +path) for identity comparison and retains a different source spelling +MUST record that spelling in the optional +`materialization_repo_url` field. The consumer MUST validate that +`materialization_repo_url`, under the same host-specific repository +normalization rule defined by [req-rs-016](#req-rs-016), identifies +the same package as `repo_url`; a mismatch MUST fail closed. It MUST +NOT use `materialization_repo_url` as an identity, deduplication, +cache, sort, or trust key, and its presence MUST NOT change +`lockfile_version`. + +> **Note:** When `materialization_repo_url` is absent the consumer +> derives materialization paths from `repo_url` directly; +> absence is not an error. + +When reconstructing a dependency, materializing it under +`apm_modules/`, or generating a relative link back to that +materialization, the consumer MUST prefer the retained source +spelling. Case-folding applies only to repository-identity path +components; an in-repository `virtual_path` and virtual-file leaf +remain case-sensitive. If exactly one existing package path differs +only in case-foldable repository components, the consumer MUST either +migrate it transactionally to the retained spelling or fail without +creating a duplicate. If multiple physical paths match one identity, +the consumer MUST fail closed without deleting any candidate path. + +For this requirement, a transactional migration completes every +case-only rename before the retained path is used, journals each +completed rename, and restores the original spelling before returning +from any caught failure. A consumer MAY use a temporary sibling path +when its filesystem cannot apply a case-only rename directly; the +temporary path MUST remain inside the materialization root and MUST +NOT be treated as an installed package. This rollback contract does +not claim process-crash atomicity; an interrupted temporary path is +recovery state that a consumer MUST preserve for inspection rather +than delete without verification. + + +**[req-lk-020]** When an install that is not frozen under +[req-lk-006](#req-lk-006) rewrites `deployed_files` or +`local_deployed_files` and the manifest declares a `target` field, a +conforming **consumer** implementation MUST preserve paths attributable +to (a) the current install targets, (b) another declared target, or +(c) an implementation-recognized target whose activation is outside +the manifest target field. A path is attributable to a target when its +top-level deploy root, Registry-documented filename pattern, or +target-specific URI scheme identifies that target; shared deploy roots +are partitioned by the filename patterns required by +[req-tg-002](#req-tg-002). It MUST remove a prior path attributable to +none of those targets. This reconciliation applies identically to +per-entry `deployed_files` and top-level `local_deployed_files`, and the +consumer MUST apply each preserve-or-remove decision to the +corresponding `deployed_file_hashes` or `local_deployed_file_hashes` +entry. If the manifest does not declare a `target` field, or the +consumer cannot determine which target governs a prior path, the +consumer MUST preserve that path and its corresponding hash entry +rather than remove it solely because it was not written by the current +install. +During orphan cleanup, the consumer MUST preserve any path freshly +deployed by an active dependency in the current install, even when the +same path is also recorded by a prior lockfile entry under a different +dependency identity. + + +**[req-lk-021]** When a non-frozen install, compile, or update +rewrites deployed state and the implementation maintains merge-based +hook configuration (a shared, +non-per-file configuration document for a target that supports the +`hooks` primitive type, together with an ownership record identifying +which entries the consumer itself wrote), a conforming **consumer** +implementation MUST apply the same preserve-or-remove decision defined +by [req-lk-020](#req-lk-020) to that merge-based hook configuration. +For a consumer-owned entry attributable to a specific dependency, +"current install targets" in clause (a) below means that dependency's +effective intersection under +[req-tg-008](#req-tg-008). +It MUST remove only the consumer-owned entries -- and any ownership +record left empty by that removal -- attributable to a target that is +not attributable to (a) the current install targets, (b) another +declared target, or (c) an implementation-recognized target whose +activation is outside the manifest target field. It MUST preserve +every entry that does not carry the consumer's own ownership +attribution, regardless of target, and every consumer-owned entry for +a target that remains attributable under (a)-(c). + +A well-formed ownership attribution to a foreign or unresolvable owner +identity does not identify an entry as consumer-owned and MUST be +preserved. If the manifest does +not declare a `target` field, or the consumer cannot determine which +target governs a prior entry, the consumer MUST preserve that entry +and its ownership attribution, mirroring +[req-lk-020](#req-lk-020)'s indeterminate case. +If the merge-based hook configuration document is already absent for a +target while its ownership record remains, a conforming consumer MUST +still apply this requirement's preserve-or-remove decision to that +orphaned ownership record: after verifying the record is well-formed, +it MUST remove a record attributable to none of (a)-(c) above, and MUST +preserve a record attributable to a target that remains attributable +under (a)-(c). A consumer that encounters a merge-based hook +configuration document or ownership record that is malformed or +cannot be parsed MUST leave that document or record unmodified and +emit an actionable diagnostic naming the affected path, rather than +partially or silently repairing it. This requirement does not mandate +how ownership is recorded (inline marker vs. a separate ownership +record) or which merge-hook targets exist; it binds only the +preserve-or-remove decision once ownership is determinate. + + +**[req-lk-016]** A conforming **consumer** implementation MUST emit +hash values as `:` envelopes (for example +`sha256:abcd...`) in every position where this specification records +a digest: `resolved_hash`, `deployed_file_hashes` (each value), +`local_deployed_file_hashes` (each value), `content_hash`, +`tree_sha256`, and any future hash field. The `` token MUST +be one of `sha256`, `sha384`, or `sha512` per +[req-mf-018](#req-mf-018). Readers MUST accept bare 64-character +lowercase hexadecimal values as `sha256:` for +backward-compatibility; writers MUST emit the explicit envelope +form. This revision retains bare-hex reader tolerance and does not +narrow the allowed envelope algorithms. Any removal of reader +tolerance remains reserved for a future revision. + + +**[req-lk-017]** A conforming **consumer** implementation +executing a frozen install (see +[req-lk-006](#req-lk-006)) MUST re-verify every entry in +`deployed_file_hashes` and `local_deployed_file_hashes` against the +on-disk content, hashed over the canonical domain defined by +[req-lk-012](#req-lk-012), and MUST fail closed on mismatch. The +diagnostic MUST name the offending path, the expected envelope, and +the observed envelope. The same re-verification MUST run on `apm +audit`. + +### 5.3 Self-entry semantics + +A project that ships its own primitives records the files it deploys +under `local_deployed_files` and `local_deployed_file_hashes` at the +top level. When the lockfile is loaded, consumers MAY synthesize an +in-memory virtual dependency entry keyed by `"."` for uniform +iteration over owned files. The synthesized entry MUST NOT be written +back to YAML; the flat `local_deployed_*` fields are the on-disk +source of truth. + +The synthesized entry, when present in memory, has: + +- `repo_url: ` +- `source: local` +- `local_path: "."` +- `depth: 0` +- `is_dev: true` + +This isolation prevents the orphan-cleanup logic of one dependency +from removing files attributed to another (see +[Section 10.7](#107-unverified-content-cleanup-file-integrators)). + +### 5.4 Lockfile versions (1, 2) and bumping rules + +This specification defines two lockfile schema versions, `"1"` and +`"2"`. Both are valid on-disk formats; `"2"` is a strict superset of +`"1"`. Editorial reconciliation with the companion is consolidated +in [Appendix E](#appendix-e-editorial-reconciliation-notes). + + +**[req-lk-002]** A conforming **consumer** implementation MUST set +`lockfile_version: "2"` when at least one entry in `dependencies` +has `source: registry`. When no entry has `source: registry`, the +consumer MAY emit either `"1"` or `"2"`. The version is +**monotonic**: once a consumer writes `lockfile_version: "2"` to a +given lockfile, subsequent rewrites of that lockfile by any +conforming consumer MUST NOT demote the version to `"1"`, even if +the registry-sourced entry is removed. A consumer SHOULD tolerate +reading either `"1"` or `"2"` regardless of which version it +prefers on write. + + +**[req-lk-004]** A conforming **consumer** implementation MUST refuse +to operate on a lockfile whose `lockfile_version` value is not one +of the versions it recognises, with a diagnostic that explicitly +offers the user a choice of either upgrading the consumer or +regenerating the lockfile from the manifest. + +### 5.5 Drift and integrity model + +The lockfile supplies dependency identities and recorded deployment ownership +for audit. Replay compares those records and the installed files against +source-derived output under current target intent, not a historical target +selection inferred from ownership. + + +**[req-lk-023]** A conforming **consumer** that replays primitive integration +to audit drift MUST select the current targets in this order: the applicable +scope's validated manifest `target` / `targets` declaration, then the saved +user target configuration, then the existing target-detection rules. A present +invalid manifest declaration, or an invalid saved target when that fallback +is selected, MUST produce a failing target-resolution result rather than fall +through to a lower-precedence source. +Explicit selection and detection are distinct: reading a saved target does +not turn a filesystem signal into a registered detection predicate. + +The consumer MUST resolve the selected profiles in the live operation's +scope and preserve their deployment layout and experimental or runtime +prerequisites. Filesystem replay MUST rebase their destinations into isolated +scratch roots; it MUST NOT carry live native or user roots into write +operations. An unavailable selected target MUST produce a target-resolution +failure, not a successful empty replay. + +Replay MUST derive expected paths and bytes from the resolved sources and +current target intent, independently of deployment ownership records. It MUST +NOT use those records to recover an earlier unsaved `install --target` +override or to suppress an expected file whose ownership is missing. Changing +current intent MUST NOT remove existing claimed files under a formerly +selected target from drift comparison. For installed-file comparison, a +directory claim covers its contained descendant files. A file-shaped claim, +including one identified by a recorded hash, does not become a directory-prefix +claim merely because its live path becomes a directory. Membership remains +within the applicable deployment-root containment boundary. Claims widen +installed-file comparison only, not expected output. An unavailable recorded +native root MUST produce a failing comparison result rather than silently +exclude its claims. + +Replay and comparison MUST NOT modify the live manifest, lockfile, saved +configuration, or deployed bytes, including native databases and sidecars. +If a selected native runtime has no isolated replay backend, the consumer MUST +report an unsupported-replay failure before invoking its live writer. +Successful target selection alone does not imply replay support. This does +not authorize repair operations or weaken [req-sc-001](#req-sc-001) +content-integrity checks or [req-pl-016](#req-pl-016) invalid-owner failures. + +**Local-content composition (informative).** Replay under +[req-lk-023](#req-lk-023) derives expected output from content admitted under +[req-mf-016](#req-mf-016) clause (d), then applies the +[req-sc-015](#req-sc-015) source plan to that admitted content. An internal +resource link correctly dereferenced into a regular file during acquisition +is not obsolete merely because its original representation was a symlink. +This does not authorize following symlinks in the target source plan or +relaxing archive-link rejection. + +**Result and exit scope (informative).** A failing target-resolution, +replay, or comparison result is not a successful empty replay, even when +the command reports it advisory in default mode. This requirement does not +add an unconditional nonzero exit for bare audit. Ordinary source-derived +drift and incomplete or unsupported replay remain subject to +[req-pl-014](#req-pl-014); CI/conformance audit gates failed aggregate +checks, while a passed advisory cache-miss skip is not a failure. +Independent hard integrity obligations remain in force, including +[req-lk-003](#req-lk-003), [req-lk-017](#req-lk-017), and +[req-pl-016](#req-pl-016). A separately requested repair is not replay +and receives no mutation authorization from this requirement. + + +**[req-lk-005]** A conforming **consumer** implementation MUST treat +two lockfiles as semantically equivalent if they differ only in the +presence or values of `generated_at` and `apm_version`. A no-op +install operation MUST NOT rewrite a lockfile whose only changed +fields would be these two. `generated_at` is optional, advisory +metadata. Consumers MUST omit `generated_at` from newly created +lockfiles unless explicit user or deployment configuration requests +it. When an existing lockfile omits `generated_at`, a consumer MUST +NOT reintroduce it solely as metadata during a later write unless +that configuration opts in. Consumers operating in privacy-sensitive +deployments SHOULD omit both provenance fields to avoid leaking tool +version or build-time information. When a consumer writes a +lockfile, the `dependencies` list MUST be +ordered ascending lexicographically by the tuple (`repo_url`, +`virtual_path`); entries without `virtual_path` sort as if +`virtual_path` were the empty string. Two lockfiles differing +only in entry order are semantically equivalent under this +requirement, but a write-back MUST canonicalise to the pinned +order so frozen-install diffs are stable across implementations. + + +**[req-lk-006]** A conforming **consumer** implementation MUST +support a frozen-install mode in which the lockfile is never written +or rewritten. Before any lockfile, target configuration, deployment, +or cache mutation (target configuration means on-disk state a +target-deploy step may write per +[Section 8.5](#85-deploy-directory-contract-normative); cache mutation +means persistent resolver or materialiser state per +[Section 7.2](#72-resolution-algorithm)), the install MUST fail when +the lockfile is absent, when any direct package dependency has no pin, +or when the manifest's direct MCP declarations differ from the +lockfile's recorded MCP server names or configurations. Direct MCP +declarations comprise entries under both `dependencies.mcp` and +`devDependencies.mcp`. A mismatch exists when the set of declared MCP +names differs from either the names in `mcp_servers` or the keys in +`mcp_configs`, or when a shared name's derived configuration is not +key-order-insensitive structurally equal to its `mcp_configs` value. +Variable placeholders are compared as literal strings, not expanded. +An operation whose effect would insert, remove, or modify a manifest +dependency entry MUST be rejected in frozen mode before that +modification takes effect. The frozen-install operation is opt-in in +this revision via `--frozen` (or equivalent). A default of "frozen +when a lockfile is present" remains reserved for a future revision +(see +[Section 9.2](#92-breaking-vs-non-breaking-change-definition)). + + +**[req-lk-018]** A conforming **consumer** implementation SHOULD +default to frozen-install behaviour when the `CI` environment +variable is truthy (defined as: present and not the literal strings +`""`, `"0"`, `"false"`, case-insensitive). The user MAY override +the SHOULD-default via explicit non-frozen invocation. This +SHOULD-on-CI rule does not activate the reserved general default +change in [req-lk-006](#req-lk-006). + + +**[req-lk-007]** A conforming **consumer** implementation SHOULD +skip the download step when a local checkout already matches the +locked commit. This optimisation MUST NOT change observable +behaviour; the post-install workspace state MUST be identical to a +fresh install. + +### 5.6 git-semver fields (constraint, resolved_tag, resolved_at) + +When the resolver picks a git tag from a semver range (see +[Section 7.3](#73-git-semver-resolution)), it records three +additional fields on the resolved entry. These fields are valid in +both `lockfile_version: "1"` and `"2"` (see [req-lk-002](#req-lk-002)). +The presence of `constraint` identifies the git-semver shape. A +git-literal entry updated under [req-rs-017](#req-rs-017) has no +`constraint`; its optional `resolved_tag` is advisory provenance and +does not change replay or trust semantics. + + +**[req-lk-008]** A conforming **consumer** implementation MUST +record `constraint`, `resolved_tag`, and `resolved_at` on every +git-semver lockfile entry. `constraint` MUST be the original semver +range from the manifest (verbatim). `resolved_tag` MUST be the +literal tag string the range resolved to. `resolved_at` MUST be an +ISO 8601 UTC timestamp of the resolution event and is advisory; it +MUST NOT be used as a tie-breaker in replay. + + +**[req-lk-009]** A conforming **consumer** implementation MUST +replay a previously locked git-semver resolution (reusing the +locked `resolved_tag`) when the manifest's current semver constraint +is **equal** to the locked `constraint`. A different manifest +constraint MUST trigger re-resolution against the remote. + + +**[req-lk-010]** A conforming **consumer** implementation MUST, when +performing an explicit update operation against a direct git-semver +dependency, purge the dependency's install path before re-resolving +so that the download callback re-runs even when the resolved tag is +unchanged. This guards against the regression where a cached +install path masks a re-resolution event. + +#### 5.6.4 Git-source tree integrity hash + +`resolved_commit` is a SHA-1 identifier and serves as a stable +content pointer in this revision, but SHA-1 alone is below the 2026 +collision-resistance floor and MUST NOT be relied on as the sole +integrity anchor. To close the SHA-1 gap, every git-sourced lockfile +entry carries a `tree_sha256` envelope. + +The **canonical git tree hash** for `tree_sha256` is the SHA-256 +over the following byte representation of the resolved tree: + +``` + ::= SP SP LF + ::= * (entries sorted lexicographically by name) +``` + +`` is the four- or six-digit POSIX-style file mode +(`100644`, `100755`, `120000`, `040000`); `` is the +filesystem name; `` is the lowercase hexadecimal +SHA-256 of the blob bytes. Subdirectories recurse: a subdirectory +entry uses mode `040000` and its blob-sha256 is itself the SHA-256 +of the subdirectory's canonical tree representation. Lines are +LF-terminated and UTF-8 encoded; entries are sorted byte-wise by +name. + + +**[req-lk-015]** A conforming **consumer** implementation MUST +compute and record `tree_sha256` for every git-sourced lockfile +entry. On a frozen install (see [req-lk-006](#req-lk-006)) and on +`apm audit`, the consumer MUST re-compute `tree_sha256` from the +working tree at `resolved_commit` and MUST fail closed when the +recomputed value differs from the recorded value. The diagnostic +MUST name the entry, the expected envelope, and the observed +envelope. + +> **Retained interoperability limits (informative).** The canonical Git-tree +> definition does not explicitly settle symlink blob bytes versus dereferenced +> contents, gitlinks/submodules with mode `160000`, or raw Git blobs versus +> CRLF/LFS-filtered checkout bytes. This corrective revision supplies no new +> cross-platform canonical-tree evidence resolving those boundaries. +> [req-lk-015](#req-lk-015) and the digest construction above remain unchanged; +> these limitations are not exemptions. Trusted-local dereferencing semantics +> are not imported into Git hashing. A canonical-byte clarification and +> cross-platform fixtures require separately scoped work. + +> **Unresolved encoding defect (informative).** The retained LF-delimited +> encoding permits distinct trees to have identical canonical bytes when a +> filename contains LF, without a SHA-256 collision. This newly demonstrated +> inherited specification debt is not a local-source/audit regression or a +> demonstrated reference-CLI exploit. A separately authorized normative +> canonical-byte brief is required. Disclosure does not repair the defect or +> waive [req-lk-015](#req-lk-015); the finding remains unresolved. + +> **Editorial note.** `resolved_commit` is a SHA-1 identifier. The +> git project's SHA-1-to-SHA-256 object-format transition is +> ongoing; until SHA-1 collisions are observed in the wild against +> the git object format, `resolved_commit` is retained as the +> canonical pointer. The unresolved LF-filename encoding defect above limits +> `tree_sha256` integrity despite SHA-256's collision resistance. +> A future revision will track `resolved_commit_sha256` +> once git's SHA-256 object-format is widely deployed. + +> **Editorial note.** Canonical-tree definition for local-path +> `content_hash` remains reserved for a future revision; consumers MAY use +> platform-native walk order but MUST document their choice in their +> conformance statement. + +### 5.7 Conformance requirements (lockfile) + +This section's normative statements are: + +- Consumer: [req-lk-001](#req-lk-001), [req-lk-002](#req-lk-002), + [req-lk-003](#req-lk-003), [req-lk-004](#req-lk-004), + [req-lk-005](#req-lk-005), [req-lk-006](#req-lk-006), + [req-lk-008](#req-lk-008), [req-lk-009](#req-lk-009), + [req-lk-010](#req-lk-010), [req-lk-011](#req-lk-011), + [req-lk-012](#req-lk-012), [req-lk-013](#req-lk-013), + [req-lk-014](#req-lk-014), [req-lk-015](#req-lk-015), + [req-lk-016](#req-lk-016), [req-lk-017](#req-lk-017), + [req-lk-019](#req-lk-019), [req-lk-020](#req-lk-020), + [req-lk-021](#req-lk-021), [req-lk-022](#req-lk-022), + [req-lk-023](#req-lk-023). +- Consumer (SHOULD): [req-lk-007](#req-lk-007), + [req-lk-018](#req-lk-018). + +--- + +## 6. Policy format (apm-policy.yml) + +### 6.1 Loading and discovery + +A Governance implementation reads zero or one `apm-policy.yml` file +per install operation. + + +**[req-pl-001]** A conforming **governance** implementation MUST +discover the active policy in the following priority order: (1) an +explicit `--policy ` argument provided by the user; (2) any +registered **discovery provider** invoked in the configured order +(see [Section 6.1.1](#611-discovery-providers) and +[req-pl-011](#req-pl-011)). No other discovery mechanism MAY be +substituted for this order. When no provider yields a policy, no +policy is applied. + +#### 6.1.1 Discovery providers + +This revision defines discovery as a **pluggable extension point**. +A discovery provider is a named function that, given a project's +remote git context, MAY return a policy reference (URL or local +path). The reference initial provider registered by this +specification is `github-owner-dotgithub`, which fetches +`/.github/apm-policy.yml` from the same host as the +project's remote when the remote host matches the consumer's +implementation-default host. Additional providers (for example +`gitlab-project-yml`, `local-fallback`) are registered in the +non-normative **"OpenAPM Discovery Provider Registry v0.1"** +companion document. + + +**[req-pl-011]** A conforming **governance** implementation MUST +expose discovery as a registered, ordered list of providers (the +default order is implementation-defined and MUST be documented in +the conformance statement). Providers MUST be selectable per +project via the policy `discovery:` block. A consumer MUST NOT +hard-code a host-specific discovery convention as the sole +discovery path. The `github-owner-dotgithub` provider is the +registered v0.1 default, NOT a specification-mandated discovery +mechanism. + + +**[req-pl-012]** A conforming **governance** implementation that +needs to identify the "project's remote" for discovery MUST select +the git remote named `origin` if present; if `origin` is absent but +exactly one git remote exists, that remote MUST be used; if +multiple non-`origin` remotes exist, discovery MUST fail closed +with a diagnostic naming the candidates; if no remote exists, no +discovery is attempted. + +#### 6.1.2 Interoperability note (informative) + +Because the default discovery host is implementation-defined (see +[req-mf-019](#req-mf-019)), two conformant Consumers MAY yield +different policy discoveries for the same project when they +default to different hosts (for example, one defaults to +`github.com`, another to `gitlab.com`). Projects wanting +deterministic discovery across heterogeneous Consumers SHOULD (a) +declare `default_host:` explicitly in `apm.yml`, and (b) select +the discovery provider explicitly in `apm-policy.yml`'s +`discovery:` block. No new normative statement is added by this +note; it is interpretive guidance for the existing MUSTs in +[Section 6.1.1](#611-discovery-providers). + +### 6.2 Enforcement modes + +A policy declares an `enforcement` value of `off`, `warn`, or `block`. + +| Mode | Behaviour | +|---------|------------------------------------------------------------------------------| +| `off` | Policy is reported but never gates an operation. | +| `warn` | Violations print warnings, exit code remains 0. Default. | +| `block` | Violations print errors and exit non-zero; install aborts before disk write. | + +When `fetch_failure` is unset, the effective value is `warn`. The +default applies independently of the `enforcement` mode default and +is the value a conforming governance implementation MUST use when +the field is absent from the policy document. + + +**[req-pl-002]** A conforming **governance** implementation MUST, when +the effective `enforcement` value is `block` and at least one +violation is detected, cause the install operation to abort +**before** any byte is written to disk for the proposed install. + + +**[req-pl-010]** A conforming **governance** implementation MUST, +when the effective `fetch_failure` value is `block` and the policy +cannot be fetched or parsed, abort the install operation with a +fail-closed diagnostic. The same MUST hold when `fetch_failure` is +`block` and a transitively `extends:`'d policy fails to fetch. + +### 6.3 Field reference + +#### 6.3.1 `dependencies` + +The `dependencies` policy block governs APM dependency declarations. + +| Field | Semantic | +|------------------------|-------------------------------------------------------------------------------------------| +| `allow` | List of patterns matched against the canonical host-blind dependency package path: repository coordinate plus any virtual path, with the `#` suffix excluded. Tri-state (see [Section 6.5](#65-allow-list--deny-list-tri-state-semantics)); case treatment per [req-pl-018](#req-pl-018). | +| `deny` | Always wins over `allow`; case treatment per [req-pl-018](#req-pl-018). | +| `require` | Exact packages required in the consumer manifest; case treatment per [req-pl-018](#req-pl-018). | +| `require_resolution` | `project-wins` / `policy-wins` / `block` for required-package version conflicts. Default `project-wins` when unset. | +| `max_depth` | Maximum transitive dependency depth. Default 50. | +| `require_pinned_constraint` | When true, flags unbounded direct deps as violations. | + + +**[req-pl-007]** A conforming **governance** implementation that +honours `require_pinned_constraint: true` MUST flag, as a violation +routed through the active `enforcement` value, every **direct** APM +dependency whose constraint is one of: (a) no `ref` at all; (b) the +literal `*`; (c) a bare branch name; (d) an unbounded lower-bound +range such as `>=X.Y` without an upper bound. Transitive dependencies +MUST NOT be flagged by this rule. + + +**[req-pl-008]** A conforming **governance** implementation that +honours `require_pinned_constraint: true` MUST classify the following +as **pinned** (no violation): (a) a 40-character commit SHA; (b) a +literal semver tag matching `v?\d+\.\d+\.\d+`; (c) a bounded semver +range (containing an upper bound); (d) a dependency with +`source: registry`; (e) a local-path dependency. + + +**[req-pl-018]** A conforming **governance** implementation MUST apply +the following dependency-policy identity rules: + +(a) The match subject is the canonical, host-blind dependency package +path: its repository coordinate plus any virtual in-repository path, +with any `#` reference suffix excluded. A registry name is never part +of this match subject and is compared literally wherever it appears in +policy operands. This +statement governs glob matching for `dependencies.allow` and +`dependencies.deny`, plus exact identity matching for +`dependencies.require`. A `require` entry is exact: `*` characters +are literal, and its package portion is the text before the first +`#`. This statement changes case treatment only; it does not change +which subject string is matched. Normalization under this statement +applies at match time only. Policy-chain intersection, union, and +deduplication under [Section 6.4](#64-inheritance-and-merge-rules) +compare authored entries byte-exactly. + +(b) A Governance implementation MUST apply to policy operands the +repository path case rule disclosed under +[Section 11.2 item 6](#conformance-statement-case-rule). This is the same +rule governed by [req-rs-016](#req-rs-016) clause (3) where the +implementation also claims the Consumer class. A +registry-sourced dependency, including one resolved through a +registry prefix, has case-insensitive repository-coordinate segments; +this source rule applies regardless of the case rule for its host. +Local-path and marketplace dependencies, plus dependencies on every +host not documented as case-insensitive under +[Section 11.2](#112-how-to-claim-conformance), remain case-sensitive. +The Governance implementation MUST NOT substitute a different case +rule from the one disclosed for that host or registry source. + +(c) The dependency subject determines a repository-coordinate segment +count N, before any virtual in-repository path. The same N bounds both +operands: only the first N U+002F-separated segments of the subject and +pattern are eligible for case-insensitive comparison. Normalization +maps only U+0041 through U+005A to U+0061 through U+007A. Every other +code point is compared literally; Unicode +case folding, locale-sensitive mapping, and normalization forms are +outside this revision per [Section 1.4](#14-terminology-preliminaries). +For the avoidance of doubt, this statement never applies outside the +fields named in clause (a): normalization MUST NOT extend into a +virtual in-repository path, a reference suffix after `#`, a registry +name, an MCP server name, or an unmanaged-file workspace path. + +(d) For a glob pattern, the eligible prefix from clause (c) is further +truncated to the segments strictly before the first pattern segment +that contains `**`. The effective case-insensitive prefix is therefore +the lesser of N and that segment index; every remaining segment on +both operands is compared byte-exactly. A fused `**` is a `**` token +sharing a segment with one or more other characters, for example +`Sec**`. + +(e) `dependencies.deny` MUST retain precedence over +`dependencies.allow` after normalization. Where clause (b) or clause +(d) requires byte-exact matching, a deny pattern that differs only in +case does not match. Policy authors who intend to deny multiple +distinct spellings on such a source or after such a truncation need to +enumerate those spellings. The residual security boundary is described +in [Section 10.8](#108-policy-bypass-via-crafted-manifest). + +(f) A conforming governance implementation MUST evaluate every +pattern-bearing policy field named in +[Section 6.5](#65-allow-list--deny-list-tri-state-semantics) under that +section's pattern grammar. It MUST NOT add character-class, brace, or +escape expansion. + +#### 6.3.2 `mcp` + +The `mcp` block governs MCP server declarations, including +transitive ones. + +#### 6.3.3 `compilation` + +The `compilation` block governs `apm compile` outputs. Sub-field +semantics are documented in the companion `policy-schema.md` and +are non-normative in this revision; the merge rules in +[Section 6.4](#64-inheritance-and-merge-rules) reference only the +field family `compilation.*`. + +#### 6.3.4 `manifest` + +The `manifest` block governs the shape of the consumer manifest +itself (required fields, scripts allow/deny, explicit-includes +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` +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 use the syntax in +[Section 6.5](#65-allow-list--deny-list-tri-state-semantics), but +workspace paths remain byte-exact and case-sensitive; dependency +identity normalization under [req-pl-018](#req-pl-018) does not apply. + + +**[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` + +The `security` block declares opt-in supply-chain controls. Every +key defaults to `false` (off), so a policy that omits the block +behaves exactly as it did before these keys existed. + +- `security.integrity.require_hashes` (boolean, default `false`): + when `true`, the install operation fails closed if any resolved + non-local dependency selected for installation lacks a recorded + content hash (the `content_hash` lockfile field) in `apm.lock.yaml`. + Local dependencies are exempt (they are anchored by deployed-file + hashes, not a package digest). +- `security.audit.fail_on_drift` (boolean, default `false`): when + `true`, a bare `apm audit` exits non-zero if lockfile drift is + detected or the drift check cannot complete. + +The normative behaviour for both keys is specified in +[Section 6.8](#68-integrity-controls-governance). Both merge by +logical OR across an `extends:` chain (see +[Section 6.4](#64-inheritance-and-merge-rules)): once any ancestor +sets a key to `true`, a descendant cannot relax it back to `false`. + +### 6.4 Inheritance and merge rules + +Policies form a chain via `extends:`. + + +**[req-pl-003]** A conforming **governance** implementation MUST +limit the `extends:` chain depth to **5** layers maximum and MUST +reject cycles in the chain with a diagnostic naming the cycle +members. + + +**[req-pl-004]** A conforming **governance** implementation MUST +pin an `extends:` reference to the host class (see +[Section 10.3](#103-token-leakage-across-hosts)) of the **leaf** +policy. A policy fetched from one host class MUST NOT extend a +policy fetched from any other host class; cross-host-class +`extends:` MUST be rejected at parse time. + + +**[req-pl-006]** A conforming **governance** implementation MUST +merge a policy chain per the following table: + +| Field family | Merge rule | +|---------------------------------------|------------------------------------------------------------------------| +| `enforcement` | Stricter wins (`block` > `warn` > `off`). | +| `fetch_failure` | Child overrides if set. | +| `*.allow` lists | Set intersection, byte-exact on each entry's UTF-8 text. `null` is transparent. | +| `*.deny` lists | Union, deduplicated byte-exact, parent order preserved. | +| `*.require` lists | Union, deduplicated byte-exact, parent order preserved. | +| `dependencies.max_depth` | `min(parent, child)`. | +| `dependencies.require_resolution` | Stricter wins (`block` > `policy-wins` > `project-wins`). | +| `mcp.self_defined` | Stricter wins (`deny` > `warn` > `allow`). | +| `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`). | + +These merge operations compare authored entries before dependency +evaluation. Case-variant entries remain distinct during union and +deduplication, and case-variant `allow` entries can intersect to an +empty list. [req-pl-018](#req-pl-018) normalization applies only when +the merged policy is matched against a dependency subject. + +### 6.5 Allow-list / deny-list tri-state semantics + + +**[req-pl-005]** A conforming **governance** implementation MUST +treat list-valued allow/deny/require fields as a three-state value: +(a) field omitted (or explicit `null`) means "no opinion" and is +transparent during merge; (b) explicit empty list `[]` means +"explicitly empty" and overrides the parent for that field; (c) +non-empty list `[...]` carries the listed entries and merges per the +table in [Section 6.4](#64-inheritance-and-merge-rules). + +**Pattern grammar (normative through [req-pl-018](#req-pl-018) +clause (f)).** Pattern-bearing `dependencies.allow`, +`dependencies.deny`, MCP allow/deny, and `unmanaged_files.exclude` +entries are matched against the whole subject and are anchored at +both ends. `*` matches zero or more characters other than U+002F +(`/`). `**` matches zero or more characters including U+002F, +including when fused with other characters in one segment. Every +other character is literal; there is no character-class, brace, +or escape expansion. [req-pl-018](#req-pl-018) changes only case +treatment for dependency identities and does not change this grammar. + +### 6.6 Forward compatibility + + +**[req-pl-009]** A conforming **governance** implementation MUST +emit a warning (never a parse error) when it encounters an unknown +top-level key in `apm-policy.yml`. This guarantees newer policy +files load on older clients without breaking the install. +Vendor-extension keys matching `x-[a-z][a-z0-9-]*` are recognised +per [req-ext-001](#req-ext-001) and MUST NOT produce a warning; +they are preserved silently. See also +[req-ext-001](#req-ext-001). + +### 6.7 Worked example (informative) + +A small policy chain: + +```yaml +# .github/apm-policy.yml in contoso/.github +name: contoso-baseline +extends: contoso-enterprise/policy +enforcement: block +fetch_failure: block + +dependencies: + allow: + - contoso/* + - microsoft/apm-skills-* + deny: + - "*/legacy-*" + require: + - contoso/security-baseline + require_pinned_constraint: true + max_depth: 25 + +manifest: + scripts: deny + required_fields: + - description + - license +``` + +### 6.8 Integrity controls (governance) + +The `security.integrity` and `security.audit` blocks declare opt-in, +fail-closed controls. [req-pl-013](#req-pl-013) and +[req-pl-014](#req-pl-014) are default-off; a policy that omits these +two controls is unaffected by them. [req-pl-016](#req-pl-016) is an +exception: it defines an unconditional integrity invariant that +applies regardless of policy configuration, independent of any +`security.audit` control. + + +**[req-pl-013]** A conforming **governance** implementation that +honours `security.integrity.require_hashes: true` MUST fail the +install operation with a fail-closed diagnostic when any resolved +non-local dependency selected for installation lacks a recorded +content hash (the `content_hash` lockfile field) in `apm.lock.yaml`. +The same fail-closed behaviour +MUST hold when the lockfile is absent or unreadable at the point of +the check. Local dependencies are exempt; they are anchored by +deployed-file hashes rather than a package content hash. + + +**[req-pl-014]** A conforming **governance** implementation that +honours `security.audit.fail_on_drift: true` MUST cause the audit +operation to terminate with a non-zero exit status when lockfile +drift is detected, or when the drift check fails to complete (for +example, an unreadable or corrupt local dependency graph). A drift +check that is merely skipped for an advisory reason, such as a cache +miss, does not by itself alter the exit status. When +`security.audit.fail_on_drift` is absent or `false`, ordinary detected +drift MUST be reported without, by itself, altering the default-mode +audit exit status. This default-mode rule does not suppress failed +CI/conformance checks or the independent hard integrity failure in +[req-pl-016](#req-pl-016). A failed current-intent resolution, replay, +or comparison under [req-lk-023](#req-lk-023) is an incomplete drift +check, not the advisory passed skip described above. + + +**[req-pl-016]** A conforming **governance** implementation MUST treat +a canonical deployment-ledger owner (a dependency-identity reference +recorded as an owner of a deployment row in `apm.lock.yaml`) that does +not resolve to a dependency entry in `apm.lock.yaml` as a +**hard integrity failure**, independent of the `security.audit.fail_on_drift` control. This +failure is distinct from the ordinary deployed-file drift governed by +[req-pl-014](#req-pl-014): an owner is a durable ownership record +carried in the lockfile, not an edit to a deployed file, so a stale +owner MUST surface even when `security.audit.fail_on_drift` is absent +or `false`. When at least one such stale ownership record is present, +an audit operation MUST terminate with a non-zero exit status in +**both** its default and CI modes, and MUST NOT mutate any deployed +byte (for example under a strip operation) while the ownership record +remains invalid. When a deployment locator (the target-qualified +identifier of a single tracked deployment row in the ledger) resolves +to more than one owner and any one of those owners does not resolve in +`apm.lock.yaml`, the hard-failure and mutation-block obligations apply +to the entire audit operation, not only to the paths co-owned by the +stale owner. The diagnostic MUST name each affected deployment locator +together with its invalid owner reference(s), and MUST carry a single +remediation directing the operator to reconcile ownership (prune the +departed owners, then re-audit). + + +**[req-pl-017]** A conforming **governance** implementation discovering +an organization policy from Azure DevOps MUST request +`apm/apm-policy` as its primary project and repository coordinate. It +MAY use a fresh cache entry for that primary coordinate; primary and +legacy cache entries MUST remain distinct. It MAY request or use the +legacy `_apm/_apm` coordinate only when the primary request received +an HTTP 404 response. It MUST NOT try that legacy coordinate after +authentication, authorization, network, timeout, rate-limit, +malformed-response, or other non-404 failures. When the legacy +coordinate supplies a policy, the implementation MUST emit one +actionable migration warning naming `apm/apm-policy`. + +### 6.9 Conformance requirements (governance) + +This section's normative statements are: + +- Governance: [req-pl-001](#req-pl-001), [req-pl-002](#req-pl-002), + [req-pl-003](#req-pl-003), [req-pl-004](#req-pl-004), + [req-pl-005](#req-pl-005), [req-pl-006](#req-pl-006), + [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-015](#req-pl-015), [req-pl-016](#req-pl-016), + [req-pl-017](#req-pl-017), [req-pl-018](#req-pl-018). + +--- + +## 7. Dependency resolution + +### 7.1 Reference kinds + +A consumer MUST classify every dependency declaration into exactly +one of the following five reference kinds, evaluated in the order +listed: + +1. **local** -- `local-path-form` strings or object entries lacking + a `git:` and `id:` key with a `path:` to the filesystem. +2. **registry** -- object entries with `id:` and (implicit or + explicit) `registry:`. +3. **git-semver** -- object or string entries whose `ref:` value + matches a semver-range pattern (for example `^1.2.0`, `~2.0`, + `>=1.0,<2.0`). +4. **git-literal** -- git URL or shorthand with a literal `ref:` + (commit SHA, tag, branch). +5. **marketplace** -- non-normative in this revision; producer-side + authoring artifact only. + + +**[req-rs-008]** A conforming **consumer** implementation MUST +classify every dependency by the priority above as a deterministic +function of the entry alone (no remote calls, no implementation +defaults). Two conforming consumers presented with the same entry +MUST produce the same kind classification. + +### 7.2 Resolution algorithm + + +**[req-rs-001]** A conforming **consumer** implementation MUST +resolve dependencies by **breadth-first** traversal of the +dependency tree, in the **declaration order** of each manifest. +When the same package identity (`/` or registry +identity) is reached via multiple constraint paths (a "diamond"), +the consumer MUST apply the following tri-modal policy: + +1. **Intersection-pick (default).** If every reachable constraint + for the identity has a non-empty intersection, the consumer + MUST select the **highest** version satisfying every constraint + in the intersection. The selected version is recorded in the + lockfile; the chain that contributed the binding tightest + constraint is recorded as `resolved_by`. +2. **Empty-intersection fail-closed.** If the intersection of + reachable constraints is empty, the install MUST fail with a + diagnostic naming both root-to-conflict chains. Silent + first-wins resolution MUST NOT be substituted. +3. **Nest mode (reserved).** Multiple versions of the same identity + under distinct deploy paths and the corresponding on-disk layout + remain reserved for a future revision (see + [Section 4.8](#48-workspaces-reserved)). A conforming **consumer** + encountering `dependencies.conflict_resolution: nest` in a manifest + MUST refuse the install with a normative diagnostic naming the + key as reserved for a future revision and citing this section. + + +**[req-rs-013]** A conforming **consumer** implementation MUST +refuse to install a manifest declaring +`dependencies.conflict_resolution: nest`, emitting a normative +diagnostic that names the `conflict_resolution: nest` key as +reserved for a future revision and cites +[Section 7.2](#72-resolution-algorithm) clause (3). + + +**[req-rs-010]** A conforming **consumer** implementation +producing an empty-intersection diagnostic per +[req-rs-001](#req-rs-001) clause (2) MUST format the diagnostic +so that it lists, for each chain, the ordered sequence of +`/@` entries from the root manifest to +the conflicting entry, separated by `->`. Both chains MUST be +named; the diagnostic MUST be deterministic for a given install +plan. + + +**[req-rs-016]** A conforming **consumer** implementation MUST +preserve a **minimum safe repository identity** through dependency +resolution, every in-memory or persistent cache layer, shared clone +reuse, and materialisation. This identity is an implementation-private +safety boundary, not a wire artifact. It consists of: + +1. the literal authority hostname, compared case-insensitively after + ASCII lowercasing and independently of the Host class or `aliases:` + equivalence used for credential scope; +2. an explicit non-default port, where `:443` for HTTPS, `:22` for + SSH, `:80` for HTTP, and `:9418` for git transport are equivalent + to an absent port; and +3. the complete repository path after first removing all trailing + U+002F (`/`) characters and then removing at most one trailing + literal `.git` suffix. Path comparison MUST be case-sensitive by + default. A dependency with `source: registry`, including one resolved + through a registry prefix, has case-insensitive + repository-coordinate segments regardless of host documentation; + the source rule takes precedence over the host rule. A consumer MUST + apply that registry rule at every cache layer. For every other source, + a consumer MAY case-fold paths for a host it documents as + case-insensitive in its conformance statement (see + [Section 11.2](#112-how-to-claim-conformance)) only when every cache + layer applies the same rule. The same source and host rules govern + policy operands under [req-pl-018](#req-pl-018); repository identity + and policy matching MUST NOT diverge. Before comparison, a consumer + MUST NOT percent-decode the path, collapse `.` or `..` segments, or + coalesce repeated internal slashes; traversal-bearing dependency + paths remain subject to parse-time rejection. + +Credential material in URL userinfo, query strings, and fragments MUST +NOT contribute to repository identity; credential handling remains +subject to [req-sc-007](#req-sc-007). An implementation MAY +over-partition its private cache by non-credential transport context +(for example scheme or SSH username), but MUST NOT omit any minimum +identity component above. This cache identity is distinct from the +manifest canonicalisation in [req-mf-009](#req-mf-009). + +Two dependency declarations whose minimum identities differ MUST NOT +share cached source material solely because they use the same ref or +have a common repository-path prefix. A consumer MAY reuse cached +source material only when minimum identity and resolved commit are +equal, or, before a commit is known within one resolution operation, +when the literal ref tokens are character-equal. Identity and ref +equality MUST NOT override a failed integrity check; the consumer MUST +discard or re-fetch material that fails the applicable integrity +obligations in [req-lk-013](#req-lk-013) and +[req-lk-015](#req-lk-015). + + +**[req-rs-006]** A conforming **consumer** implementation MUST stop +transitive resolution at a configurable depth cap whose default value +is **50**. The Governance class MAY tighten this cap via +`policy.dependencies.max_depth` (see [Section 6.3.1](#631-dependencies)). +Exceeding the cap MUST cause the install to fail with a diagnostic +naming the chain at which the cap was reached. + +### 7.3 git-semver resolution + + +**[req-rs-003]** A conforming **consumer** implementation MUST +classify the `ref:` of any git dependency into one of three kinds: +(a) `semver` -- the ref value parses as a semver range per +[Section 7.3.1](#731-semver-dialect-normative); (b) +`literal` -- the ref value is a commit SHA, a literal tag (matching +`v?\d+\.\d+\.\d+` or a non-semver tag), or a branch name; (c) +`none` -- the entry has no `ref:` at all. + + +**[req-rs-002]** A conforming **consumer** implementation MUST, +when resolving a git-semver dependency, list the remote git tags of +the repository, dereference annotated tags to their peeled commit +object (lightweight and annotated tags are treated equivalently +thereafter), discard any tag whose name fails to parse under the +semver dialect of [Section 7.3.1](#731-semver-dialect-normative) +without diagnostic, filter the remainder to those matching the +manifest's semver range under the same dialect, and pin the +**highest** matching tag in the lockfile. Pre-release tags MUST be +excluded from selection unless explicit opt-in is signalled per +[Section 7.3.1](#731-semver-dialect-normative). The selected tag, +the original constraint, and the resolution timestamp MUST be +written per [req-lk-008](#req-lk-008). + + +**[req-rs-007]** A conforming **consumer** implementation MUST +evaluate every semver range expression in a manifest or lockfile +under the **node-semver** dialect as pinned in +[Section 7.3.1](#731-semver-dialect-normative). No +implementation-defined hedging is permitted. + +#### 7.3.1 Semver dialect (normative) + +This revision pins the semver-range dialect to **node-semver** +([https://github.com/npm/node-semver](https://github.com/npm/node-semver)) +as its normative reference, with version precedence and pre-release +ordering inherited from **Semantic Versioning 2.0.0** Section 11 +([https://semver.org/spec/v2.0.0.html](https://semver.org/spec/v2.0.0.html)). +The conformance oracle for this section is +`tests/fixtures/spec-conformance/resolution/semver-dialect.json` +(see [Section 12.4](#124-fixture-layout-informative)). + +**Range operators (normative).** + +| Operator | Semantics | +|------------|--------------------------------------------------------------------------------------------------------| +| `^x.y.z` | Compatible-with-X: matches `>= x.y.z, < (x+1).0.0` when `x > 0`; `>= 0.y.z, < 0.(y+1).0` when `x == 0` and `y > 0`; `>= 0.0.z, < 0.0.(z+1)` when `x == 0` and `y == 0`. | +| `~x.y.z` | Approximately equivalent: `>= x.y.z, < x.(y+1).0`. `~x.y` (no patch) is equivalent to `>= x.y.0, < x.(y+1).0`. | +| `>=`, `>`, `<=`, `<`, `=` | Comparator-form: standard inequality on semver precedence. | +| `x.y.z`, `*` | Wildcard: any version (subject to pre-release exclusion). | +| Range list (comma or whitespace) | Logical AND: `>=1.0.0, <2.0.0` matches versions satisfying both comparators. | +| `\|\|` | Logical OR: `^1 \|\| ^2` matches versions satisfying either range list. | +| `x.y.z - a.b.c` | Hyphen range: equivalent to `>= x.y.z, <= a.b.c`. | + +**Build metadata.** Build metadata (anything after `+` in a +version) MUST be ignored for precedence comparisons per semver +2.0.0 Section 10. Two versions differing only in build metadata +compare as equal. + + +**[req-rs-014]** When two candidate tags have equal precedence +under semver 2.0.0 Section 11 (i.e. they differ only in +build-metadata identifier), a conforming **consumer** MUST select +the tag whose name compares highest under bytewise ASCII ordering +of the full tag string. This rule eliminates non-determinism in +build-metadata ties. + +**Pre-release ordering.** Pre-release ordering follows semver +2.0.0 Section 11: numeric identifiers compare numerically, ASCII +alphanumeric identifiers compare lexicographically in ASCII order, +numeric identifiers always have lower precedence than alphanumeric +identifiers, and a larger set of pre-release fields has higher +precedence than a smaller set when all preceding fields are equal. + +**Pre-release opt-in (normative).** A pre-release tag (per semver +2.0.0 Section 9) MAY be selected only when **at least one** of the +following is true: + +1. The manifest range expression itself contains a pre-release + identifier on the same `[major, minor, patch]` tuple as the + candidate tag (node-semver "include-prerelease in same range" + semantics). For example, `>=1.2.0-alpha <1.3.0` permits + `1.2.0-beta` and `1.2.0`, but does NOT permit `1.3.0-alpha`. +2. The manifest dependency entry declares `prerelease: true` + (explicit opt-in across the whole range). + +When neither (1) nor (2) holds, every candidate tag with a +non-empty pre-release identifier MUST be discarded from the +candidate set before highest-match selection. + +**`0.x` quirk.** Per semver 2.0.0 Section 4, anything `0.x.y` is +considered unstable; the caret operator narrows accordingly as +defined above (`^0.2.3` matches `>= 0.2.3, < 0.3.0`, NOT +`>= 0.2.3, < 1.0.0`). This is the node-semver convention and is +adopted normatively. + +**Determinism.** The selection function is a deterministic +function of (range expression, candidate-tag set, opt-in +signal). Two conforming consumers presented with the same inputs +MUST select the same tag. + +### 7.4 Transitive resolution and conflict policy + +Conflicts at the **primitive** level (multiple sources providing a +primitive with the same name) are governed by +[Section 8.3](#83-priority-and-conflict-resolution). Conflicts at +the **package** level (multiple resolution paths reaching the same +package identity at different constraints) are governed by +[req-rs-001](#req-rs-001)'s tri-modal policy. + +> **Design rationale (non-normative).** The tri-modal policy +> replaces v0's silent first-wins behaviour. Empty-intersection +> fail-closed is the correctness default: a consumer that +> downgrades a dep silently to satisfy a transitive constraint +> produces audit drift the workspace owner did not author. The +> intersection-pick default is conservative; nest-mode remains reserved, +> not an available escape hatch in this revision. A future revision may introduce +> `policy.dependencies.resolver:` to let Governance pick the mode +> centrally. + +A worked example showing primitive- and package-level conflicts +composing: + +```yaml +# Manifest: +dependencies: + apm: + - acme/foo#^1.2.0 # direct: depth 1, constraint ^1.2.0 + - acme/bar#^2.0.0 # direct: depth 1, constraint ^2.0.0 +# acme/bar transitively pulls acme/foo#^1.5.0 (depth 2). +# Intersection of ^1.2.0 and ^1.5.0 is [>=1.5.0, <2.0.0]: pick highest +# tag in [1.5.0, 2.0.0) per req-rs-001 clause (1). +# If acme/bar instead pulled acme/foo#^2.0.0, intersection is empty +# and install fails closed per req-rs-001 clause (2). +``` + +Lockfile fragment for the 3-chain intersection case +(per [req-rs-010](#req-rs-010), `resolved_by` records the chain +contributing the binding tightest constraint): + +```yaml +# Three chains reach acme/foo: +# root -> acme/foo#^1.2.0 (depth 1, lo=1.2.0) +# root -> acme/bar#^2.0.0 -> acme/foo#^1.5.0 (depth 2, lo=1.5.0) +# root -> acme/baz#^3.0.0 -> acme/qux#^1.0.0 -> acme/foo#~1.7.0 +# (depth 3, lo=1.7.0) +# Intersection: [>=1.7.0, <2.0.0]. Pick highest tag in that range. +# The tightest lower bound is contributed by acme/baz -> acme/qux, +# so `resolved_by` records that chain. +dependencies: + - repo_url: github.com/acme/foo + resolved_tag: v1.7.4 + constraint: "~1.7.0" + resolved_by: "acme/baz#^3.0.0 -> acme/qux#^1.0.0 -> acme/foo#~1.7.0" + depth: 3 +``` + +### 7.5 Lockfile replay semantics + + +**[req-rs-004]** A conforming **consumer** implementation MUST treat +a manifest entry whose `ref:` is a semver range as equivalent to its +locked counterpart (no drift) when, and only when, the locked +`constraint` value is character-equal to the manifest's current +range. Any difference, including whitespace, MUST trigger +re-resolution. + + +**[req-rs-015]** A conforming **consumer** implementation performing a +non-update install (that is, not an `apm update` and not an explicit +`--refresh`/re-resolution invocation) MUST replay a lockfile entry +that records a `resolved_commit` for a git-literal or untagged-branch +entry per [req-rs-003](#req-rs-003) by reusing that recorded commit as the +resolution result WITHOUT issuing a network ref-resolution -- no +commits-API query, no `git ls-remote`, and no clone for ref discovery +(illustrative, not exhaustive) -- for that entry, provided drift +detection against the manifest reference does not require +re-resolution. An advisory `resolved_tag` recorded under +[req-rs-017](#req-rs-017) does not change this requirement's +applicability. Object fetch to materialise content at the +already-resolved commit is not constrained by this requirement. When +the manifest reference for the entry has changed so that the recorded +pin no longer matches (drift -- defined for entries scoped by this +requirement as: the manifest `ref` value is not character-equal to the +lockfile `resolved_ref` for that entry, or the entry has been removed +from the manifest; semver-range drift is governed separately by +[req-rs-004](#req-rs-004)), or under an explicit `apm update` / +`--refresh` invocation ([req-rs-011](#req-rs-011), +[req-rs-012](#req-rs-012)), the consumer MUST re-resolve the reference +over the network as usual. The recorded `resolved_commit` is the +lockfile's resolution anchor ([req-lk-003](#req-lk-003)); content +integrity remains subject to `tree_sha256` ([req-lk-015](#req-lk-015)) +and `resolved_hash` ([req-lk-013](#req-lk-013)). + +> **NOTE (non-normative).** This requirement makes a warm install of an +> already-locked reference network-free at the resolution step, which +> is what permits reproducible and offline-capable resolution for +> commit-pinned and branch-tracking entries not covered by the +> semver-range equivalence of [req-rs-004](#req-rs-004). + +#### 7.5.1 Mirror resolution + +This revision anchors trust on the recorded `resolved_hash`, not on +the recorded `resolved_url`. This permits enterprise mirrors, +content-addressable proxies, and offline caches to substitute for +the origin URL without lockfile churn. + +Registry-class implementations participating in mirror resolution +MUST satisfy [req-rg-001](#req-rg-001) (Registry-class trust +anchor). + + +**[req-rs-009]** A conforming **consumer** implementation MUST +permit the fetch of a registry-sourced dependency to be satisfied +by **any** registry declared in the project's `apm.yml` +`registries:` block, or by any policy-declared mirror, **provided +that** the bytes returned by the mirror hash to the lockfile's +recorded `resolved_hash`. The `resolved_url` field is advisory in +this revision: a mismatch between the mirror URL and `resolved_url` MUST +NOT fail the install when the hash matches. A hash mismatch MUST +fail closed per [req-lk-013](#req-lk-013), regardless of which +registry served the bytes. + +> **Editorial note (non-normative).** The mirror-tolerance +> property of [req-rs-009](#req-rs-009) holds against the recorded +> `resolved_hash`, not against bytes reconstructed from the +> upstream source. Mirror operators MUST replicate the original +> archive bytes verbatim; rebuilding the archive on the mirror +> (even from the same source revision) will produce a different +> `resolved_hash` and break the mirror-tolerance guarantee. +> Reproducible-build determinism remains reserved for a future revision (see +> [Section 1.1](#11-goals-and-non-goals) non-goals). + +### 7.6 Diagnostic surface (`deps why`) + + +**[req-rs-005]** A conforming **consumer** implementation that +exposes a "why is this dependency present" diagnostic command MUST +compute the answer by walking the lockfile **bottom-up** from the +target entry to the root, returning the set of root-to-target chains +that include the target. Chains MUST be returned in lexicographic +order of the root-to-target path tuple. The walker MUST operate +offline against the lockfile alone, MUST be safe against cycles (no +infinite recursion), and MUST produce deterministic output for a +given lockfile. + +### 7.7 Update operation + +This revision defines the semantics of an explicit "update" +operation so that two conforming consumers produce the same lockfile +delta from the same inputs. + + +**[req-rs-011]** A conforming **consumer** implementation that +exposes an `apm update` (or equivalent) command MUST, when invoked +without a package argument, re-resolve every direct dependency. +Dependencies other than full-SHA git-literal entries MUST resolve +against their **current** manifest constraint while leaving that +constraint unchanged, and the consumer MUST rewrite their lockfile +pins to the new highest matching version. A full-SHA git-literal entry +follows [req-rs-017](#req-rs-017) when the consumer offers that update +extension. The consumer MUST re-resolve all transitive dependencies as +a side-effect and MUST honour the active Governance policy's +`require_pinned_constraint` rule ([req-pl-007](#req-pl-007)). + + +**[req-rs-012]** A conforming **consumer** implementation that +exposes `apm update ` MUST scope re-resolution to the named +package and its subtree only, MUST hold every other resolved entry +at its prior pin, and MUST refuse to operate on a frozen install +(see [req-lk-006](#req-lk-006)) without an explicit override +flag. When the named package is a direct full-SHA git-literal entry +and the consumer offers the [req-rs-017](#req-rs-017) extension, that +entry follows req-rs-017; the consumer MUST NOT rewrite a transitive +package manifest. + + +**[req-rs-017]** A conforming **consumer** implementation that offers +an update extension for a git-literal dependency pinned to a full +hexadecimal commit ID MUST form one candidate set from annotated tags +whose peeled object has been verified as a commit and whose parsed +semantic version has an empty pre-release identifier under +[Section 7.3.1](#731-semver-dialect-normative). A `0.x.y` version with +an empty pre-release identifier remains eligible. The consumer MUST NOT +select a branch or lightweight tag. + +A candidate tag name MUST use one of `v{version}`, +`{name}--v{version}`, `{name}-v{version}`, or the bare `{version}`. +For a repository dependency, `name` is the final repository path +component after removing at most one trailing `.git`; for a selected +virtual subdirectory, it is the final non-empty virtual-path component. +The consumer MUST select the candidate with the highest version under +Section 7.3.1 precedence. When candidates have equal precedence, it +MUST apply the bytewise ASCII full-tag-string tie break in +[req-rs-014](#req-rs-014), independently of remote record order. A +winning candidate whose peeled commit equals the current pin is a +no-op. + +When the authoritative upstream contains no eligible tag, the consumer +MUST retain the current commit ID, emit a default-visible diagnostic, +and continue resolving other dependencies in the requested update +scope. A transport failure, an invalid or all-zero object ID, an invalid +tag refname, a duplicate tag ref record, or a peeled tag record without +its base record MUST fail the update before any manifest or lockfile +write. An otherwise eligible annotated tag that peels to a tree, blob, +or other non-commit object is also a fatal outcome, not an ignored +candidate. These failures MUST NOT be converted into the retained-pin +outcome. + +For a successful replacement, the consumer MUST rewrite only the +direct root-manifest entry's `ref` to the selected peeled commit. The +matching lockfile entry MUST record that same commit in `resolved_ref` +and `resolved_commit`, and MUST record the selected tag in +`resolved_tag` as advisory provenance. The tag does not change the +entry's git-literal reference kind and does not become a trust anchor. +A later non-update install MUST replay the full commit without remote +tag enumeration under [req-rs-015](#req-rs-015). A retained-pin outcome +MUST leave the direct manifest entry and its resolved lock values +unchanged. + +A no-argument update applies this rule to every direct full-SHA +git-literal entry. A package-scoped update applies it only to the named +direct entry; ordinary subtree resolution continues, but transitive +package manifests MUST NOT be rewritten. The consumer MUST finish +candidate validation for every scoped direct entry before writing +either the manifest or lockfile. A retained-pin outcome is entry-local +and does not stop unrelated scoped updates. Any fatal outcome named +above aborts the requested update scope and leaves both files +unchanged. + +Range-widening update modes (for example `apm update --aggressive`, +which would mutate the manifest's range upper bounds) are +**reserved for a future revision; not activated by v0.2.0**. + +### 7.8 Producer release contract + +This revision defines a minimal producer release contract so that +git-semver resolvers ([req-rs-002](#req-rs-002)) bind to the same +artifact every consumer sees. + + +**[req-pr-004]** A conforming **producer** publishing a git tag +intended for consumption via git-semver MUST ensure that the tag +points at a commit whose `apm.yml` `version` field is equal to the +tag (modulo an OPTIONAL leading `v` prefix). For example, the tag +`v2.3.1` MUST point at a commit whose `apm.yml` contains +`version: "2.3.1"`. The tag name MUST match +`^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-((0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(\+([0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*))?$` +(this matches the semver.org 2.0.0 regex modulo the optional +leading `v`). A conforming **consumer** SHOULD verify this +alignment after `git checkout` of the resolved tag and SHOULD emit +a non-blocking diagnostic on mismatch. + + +**[req-pr-005]** A conforming **producer** publishing release tags +SHOULD sign tags via a publicly verifiable mechanism (for example +sigstore, GPG, or SSH-signed git tags). Signature verification is +not enforced by this revision; the SHOULD is advisory and feeds +future provenance work (see +[Section 10.12](#1012-publisher-provenance-and-attestations-reserved)). + +Release publication itself is **out of scope** for this revision's CLI +surface. The canonical publication flow is whichever tag-and-release +mechanism the producer's git host provides; on GitHub, that is +`gh release create` or the `microsoft/apm-action mode: release` +workflow. No `apm pack --create-tag` or `apm pack --push` surface +is defined by this revision; producers MUST NOT depend on such a surface. + +### 7.9 Version withdrawal (reserved) + +Version withdrawal (yank, deprecate, supersede) for published +versions is **out of scope and reserved for a future revision**. +An informative future sketch includes `yanked: true` (not selected for +fresh resolution, possibly retained for existing locks with a warning), +`superseded_by: `, and Governance `refuse_yanked: block | warn | off`. +None is activated here. Producers needing withdrawal semantics in +this revision MUST rely on out-of-band advisories. + +### 7.10 Worked example (informative) + +A consumer with the manifest: + +```yaml +name: web-app +version: "1.0.0" +default_host: github.com +dependencies: + apm: + - contoso/security-baseline#^2.0 + - git: https://gitlab.example.com/acme/coding-standards.git + ref: main +``` + +resolves to the lockfile: + +```yaml +lockfile_version: "2" +apm_version: "0.7.0" +dependencies: + - repo_url: github.com/contoso/security-baseline + resolved_commit: "a1b2c3d4e5f6789012345678901234567890abcd" + resolved_ref: "^2.0" + constraint: "^2.0" + resolved_tag: v2.3.1 + resolved_at: "2026-05-10T20:14:00+00:00" + tree_sha256: "sha256:0102030405060708091011121314151617181920212223242526272829303132" + depth: 1 + - repo_url: gitlab.example.com/acme/coding-standards + resolved_commit: "f6e5d4c3b2a1098765432109876543210fedcba9" + resolved_ref: main + tree_sha256: "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd" + depth: 1 +``` + +The example uses `lockfile_version: "2"` even though no +registry-sourced entry is present; per [req-lk-002](#req-lk-002), +this is permitted. Once written as `"2"`, this lockfile MUST NOT +be demoted to `"1"` on subsequent rewrites. + +### 7.11 Conformance requirements (resolution) + +This section's normative statements are: + +- Consumer: [req-rs-001](#req-rs-001), [req-rs-002](#req-rs-002), + [req-rs-003](#req-rs-003), [req-rs-004](#req-rs-004), + [req-rs-005](#req-rs-005), [req-rs-006](#req-rs-006), + [req-rs-007](#req-rs-007), [req-rs-008](#req-rs-008), + [req-rs-009](#req-rs-009), [req-rs-010](#req-rs-010), + [req-rs-011](#req-rs-011), [req-rs-012](#req-rs-012), + [req-rs-013](#req-rs-013), [req-rs-014](#req-rs-014), + [req-rs-015](#req-rs-015), [req-rs-016](#req-rs-016), + [req-rs-017](#req-rs-017). +- Producer: [req-pr-004](#req-pr-004). +- Producer (SHOULD): [req-pr-005](#req-pr-005). + +--- + +## 8. Primitive type system and target matrix + +### 8.1 Primitive types + +This specification recognises **seven** primitive types: `instructions`, +`prompts`, `agents`, `skills`, `commands`, `hooks`, and `mcp`. A +primitive is a typed unit of agent configuration sourced from one of +the recognised package layouts: + +- **APM package** (`.apm/` directory). Primitives live under typed + subdirectories (`.apm/skills/`, `.apm/agents/`, ...) and are + hoisted individually into deploy directories. +- **Skill bundle** (`SKILL.md` at root, optionally with `apm.yml` = + hybrid). The whole directory is copied to + `/skills//`. +- **Skill collection** (`skills//SKILL.md` nested). Each + nested skill is promoted to `/skills//`. +- **Plugin collection** (`plugin.json` / `.claude-plugin/`). + Artifacts are mapped into deploy directories per the plugin manifest. + + +**[req-pr-006]** For a Plugin collection, a conforming **consumer** +MUST resolve `plugin.json` `skills` as follows. Only an omitted +`skills` key permits conventional `skills//SKILL.md` discovery. +A present key MUST be a string or a list of strings and replaces that +discovery: an explicit empty list contributes no skills. The plugin root +is the collection root containing `.claude-plugin/`, not the manifest +directory. Each declared path MUST be relative to that root; every +traversed path component, derived child, and `SKILL.md` MUST remain +within that root and MUST NOT be a symlink. A declared directory +containing a regular `SKILL.md` is one skill named by its final path +component; a declared directory without `SKILL.md` contributes only +immediate child directories containing a regular `SKILL.md`, each named +by its final path component. A missing, unreadable, malformed, escaping, +or symlinked entry MUST contribute no skill, and a consumer MUST NOT +fall back to undeclared root discovery. Consumers MUST canonicalize +accepted paths relative to the plugin root: repeated spellings of one +canonical source deduplicate to one leaf, while every contribution whose +derived leaf name maps to more than one canonical source contributes no +skill. Only the resulting names are eligible for enumeration, selection, +or deployment. + + +**[req-pr-007]** A conforming **consumer** implementation MUST, when +copying a declared Plugin collection component source, canonicalize the +non-symlink source root selected for that component. A consumer-generated +staging subtree is the materialization root created by the current operation +and every descendant of that root. The consumer MUST prune every such staging +subtree before traversing the source. + +A package **exposes selectable skills** when layout resolution identifies a +container for individually addressable named skill entries, whether that +container currently yields zero or more entries. This includes an APM package +with a `.apm/skills/` container, a skill collection, and a plugin collection +with a named-skills container. A skill bundle with `SKILL.md` at its root +deploys as a unit and does not expose selectable skills. + +### 8.2 Discovery and source tracking + + +**[req-pr-001]** A conforming **consumer** implementation MUST +attach a source attribution to every discovered primitive. The +attribution MUST be `local` for primitives sourced from the +project's own `.apm/` directory and MUST be of the form +`dependency:` for primitives sourced from a resolved +dependency. + +### 8.3 Priority and conflict resolution + + +**[req-pr-002]** A conforming **consumer** implementation MUST cause +local primitives to override dependency primitives of the same name +and same primitive type. The conflict MUST be recorded in the +consumer's diagnostic surface and MUST be inspectable by the user. + + +**[req-pr-003]** A conforming **consumer** implementation MUST +process dependencies in the order they are declared in the manifest +(direct deps first, transitive deps appended in lockfile order). +When two dependencies provide primitives with the same name and +same type, the **first declared** dependency wins; later +dependencies' versions MUST NOT replace the resolved primitive. + +### 8.4 Target detection signals (normative) + +Audit replay selects current intent under [req-lk-023](#req-lk-023) +before using this section's detection fallback. The saved-configuration +branch is selection, not auto-detection. + +When the user has not specified a target via `--target` or in the +manifest's `target:` field, the consumer auto-detects from +filesystem signals. The concrete table of per-target detection +signals and deploy roots is published in the non-normative +**"OpenAPM Target Registry v0.1"** companion document (see +[targets-matrix.md](../../reference/targets-matrix/)). Vendors MAY +register new targets without a spec amendment via the +`x--` extension namespace (see +[req-tg-004](#req-tg-004)). + + +**[req-tg-001]** A conforming **consumer** implementation MUST +honour the per-target detection predicate published in the +registered OpenAPM Target Registry for every spec-registered target +identifier and for every vendor-registered identifier +([req-tg-004](#req-tg-004)). Auto-detection MUST activate a target +**only** when its registered predicate fires; no other filesystem +signal MAY substitute for, or augment, the registered predicate. +A target registered without a detection predicate +MUST NOT be auto-detected and MUST be excluded from the expansion of +`all`; such an **explicit-only** target MUST be selected explicitly +via `--target `, via the manifest's `target:` field, or, for audit replay, +via a valid saved user target configuration selected under +[req-lk-023](#req-lk-023). That saved audit selection constitutes explicit +selection, not auto-detection. In this revision +the explicit-only targets are `agent-skills` and `antigravity`. When +no detection signal fires, the consumer MAY fall back to a `minimal` +profile that emits `AGENTS.md` only. + +### 8.5 Deploy directory contract (normative) + +This revision establishes `.agents/` as an **ecosystem convention**: +the cross-tool deploy root for primitives shared between targets +that opt into convergence. Per-target deploy roots are published in +the non-normative OpenAPM Target Registry v0.1 companion. + + +**[req-tg-002]** A conforming **consumer** implementation MUST +deploy primitives only under the deploy root(s) registered for the +active target in the OpenAPM Target Registry. No target's +installer MAY write files outside its registered root(s); writing +outside the registered root MUST be treated as an implementation +defect, not a runtime warning. When two targets register the same +deploy root (for example two targets that both share `.agents/`), +each target OWNS only the file-name patterns documented for that +target in the Registry; `.agents/` is partitioned by subdirectory +(`.agents/skills/`, `.agents/commands/`, `.agents/prompts/`, +`.agents/rules/`, ...) +so that distinct targets do not contend for the same on-disk +patterns. + + +**[req-tg-003]** A conforming **consumer** implementation MUST +deploy skills to `.agents/skills//SKILL.md` for every target +that supports the `skills` primitive type, unless the user has +explicitly opted out of skill-convergence via the documented +opt-out switch. This cross-tool convergence ensures a single skill +bundle serves every harness without per-target duplication. + + +**[req-tg-005]** A conforming **consumer** implementation that deploys +target-native per-file instruction rules MUST honour the registered +rule filename pattern and frontmatter mapping for the active target. +For the `antigravity` target, instruction rules MUST be written under +`.agents/rules/.md`; when the source instruction declares +`applyTo`, the emitted frontmatter MUST represent it as +`trigger: glob` plus a `globs` field. To reduce sources of divergence +in deployed-file content hashes across conforming implementations +(contributing to the canonical-content equivalence check of +[req-lk-012](#req-lk-012)), `globs` MUST be +emitted as a YAML scalar when `applyTo` resolves to exactly one glob +pattern and as a YAML block sequence when it resolves to two or more +patterns; when `applyTo` is absent or empty the emitted rule file MUST +NOT carry a frontmatter block. Compile-time deduplication MUST treat +only those files whose names derive from the currently-resolved +instruction primitives (specifically, for each resolved instruction +primitive of name N the derived filename is `.agents/rules/N.md`; the +authoritative set is the lockfile `deployed_files` list when a +lockfile is present, falling back to manifest instruction entries on +first install) as deployed rules; any other `.md` file under +`.agents/rules/` MUST NOT be treated as a deployed rule and MUST NOT +suppress instruction content in `AGENTS.md`. + +> **Editorial note.** [req-tg-005](#req-tg-005) names the `antigravity` deploy path +> and frontmatter keys in the normative text rather than delegating +> them to the non-normative Target Registry companion (contrast +> [req-tg-002](#req-tg-002)). This is a deliberate, scoped exception: +> the `antigravity` rules directory shares the `.agents/` root that +> [req-tg-002](#req-tg-002) partitions, so the deduplication and +> non-suppression guarantees above require normative precision about +> the filename derivation. A future revision MAY relocate the concrete +> `antigravity` filename pattern and frontmatter mapping to the +> Registry companion once a cross-target instruction-rule schema is +> registered, retaining only the target-agnostic honour and dedup +> MUSTs here. + +#### 8.5.1 Lossy agent conversion + + +**[req-tg-006]** A conforming **consumer** implementation that converts +an agent primitive into a target-native format MUST either preserve each +[source-declared capability restriction](#3-terminology) semantically or +emit an actionable diagnostic. Semantic preservation requires the same +effective capability ceiling (the maximal set of tools, actions, or resources +the restriction permits); a translation that widens, narrows, or cannot +represent the restriction exactly is non-preserving. The diagnostic MUST +identify the source agent and each discarded field and MUST state that exact +preservation failed. For a widening or an unrepresentable restriction, it +MUST state that the generated agent may have broader capability access; for a +verified narrowing, it MUST state that access is narrower than declared. When +several fields are discarded, one diagnostic enumerating all of them or +separate diagnostics for each field MAY be used. The diagnostic MUST appear +in the consumer's default (non-verbose) output and MUST be rendered before +the overall operation returns; this requirement does not mandate a nonzero +exit status. If source agent frontmatter cannot be parsed as a mapping, the +consumer MUST instead emit a default-visible diagnostic stating that +capability restrictions could not be verified before the overall operation +returns. + +> **Editorial note.** Concrete target-native encodings for capability +> restrictions are intentionally unspecified in this revision. A future revision +> may register them through the Target Registry companion or the amendment +> process in [Section 9.3](#93-amendment-process) without weakening the +> preservation-or-diagnostic contract above. + + +**[req-tg-009]** A conforming **consumer** implementation that deploys an +agent primitive into a target-native format with a fixed, enumerable +capability vocabulary MUST fail closed: if any source-declared tool falls +outside the target's approved capability set, the implementation MUST NOT +write the agent's target artifact (zero bytes, no partial file) and MUST +emit an actionable diagnostic identifying the unsupported tool value(s) and +the approved set. This fail-closed evaluation MUST be performed prior to any +content-identity adoption fast-path; an existing on-disk artifact whose bytes +match the source MUST NOT cause an agent with unrepresentable capabilities to +be adopted or retained in the deployed-files record. This gate is evaluated +per agent primitive independently; failure for one agent MUST NOT prevent +deployment of other, vocabulary-conformant agent primitives from the same +dependency or install operation. This evaluation applies only to agents whose +target is included in the effective intersection computed under +[req-tg-008](#req-tg-008); agents whose target is already excluded by that +intersection are not subject to this gate. + +> **Editorial note.** The approved capability set for each target is the +> vocabulary enumerated in the OpenAPM Target Registry companion entry for +> that target at the spec version the consumer declares conformance to. A +> conformance test suite MUST pin the exact companion version it validates +> against. A future revision may promote this pinning to a standalone +> normative requirement and define a machine-readable vocabulary schema; +> until then, conformance testing is scoped to the sets published in the +> companion. + +#### 8.5.2 Post-install compilation guidance + + +**[req-tg-007]** A conforming **consumer** implementation that completes a +non-dry-run, project-scope install MUST emit a default-visible, actionable +diagnostic before returning when all of the following are true: (a) at least +one package was installed during this operation; (b) the full installed +dependency tree, including packages installed during earlier operations, +contains an instruction primitive; and (c) at least one active target is +classified as requiring post-install root-context compilation in the companion +[target support matrix](../../reference/targets-matrix/#post-install-instruction-compilation). +The diagnostic MUST name the follow-up compilation operation (for example, +`apm compile` or an equivalent) and only the applicable root context output +classes (for example, `AGENTS.md`, `CLAUDE.md`, or `GEMINI.md`) for the active +targets. The consumer MUST NOT emit this diagnostic for a dry run, an +install that installed no package, an installed dependency tree without +instruction primitives, or a target set with no active target classified as +requiring post-install root-context compilation. An unclassified target MUST +NOT trigger the diagnostic by itself. + +> **Editorial note.** The presence check covers the consumer's complete +> installed dependency store because a later install can make instructions +> from an earlier dependency newly relevant to an active target. The +> requirement does not prescribe a lockfile field for this check: +> compile-only instruction sources are not necessarily deployed outputs. + +#### 8.5.3 Package-declared target restrictions + + +**[req-tg-008]** For each dependency, a conforming **consumer** +implementation MUST integrate target-scoped primitives only into the +intersection of (a) the project's currently active targets, (b) the +target subset authorized by the consumer for that dependency, and (c) +the dependency package's declared `target:` or `targets:` set when that +set is restrictive. This computed set is the dependency's **effective target +intersection**. The mechanism for (b) is implementation-defined; +when the consumer has no explicit per-dependency authorization +mechanism or subset, (b) adds no restriction. + +The package set is restriction-only: it MUST NOT activate a target or +expand either (a) or (b). Omitting both package fields, or including the +universal `all` value, adds no package-side restriction. Scalar and list +spellings under `target:` accept the aliases defined in +[Section 4.2.1](#421-target); `targets:` accepts lowercase identifiers +from the canonical set in Section 4.2.1 and the literal `all` sentinel. +The `all` token remains a literal no-restriction sentinel and MUST NOT +be expanded to the auto-detectable target set during this intersection. +A null value under singular `target:` is treated as field omission for +legacy compatibility; a consumer MUST reject an empty string or empty +list. A declaration with both fields (even when either value is null), +a null or empty `targets:` value, a `targets:` token that is neither +canonical nor `all`, or a `target:` token that does not satisfy +[req-mf-005](#req-mf-005) MUST be rejected before target-scoped +deployment with a diagnostic naming the invalid declaration or token. + +When no explicit package field exists, a consumer MAY infer an +additional legacy hook-only restriction from the final path component. +The consumer strips the final extension, lowercases the ASCII stem, and +matches either `hooks-` or a contiguous rightmost sequence of +hyphen-delimited tokens immediately before `-hooks`. It resolves that +sequence from right to left against its registered filename-target token +table; unmatched segments terminate the sequence. This filename filter +is applied after the effective intersection and MUST only narrow it. A +consumer MUST NOT infer a package restriction from a generic or otherwise +unmatched filename. Producers SHOULD migrate to explicit `target:` or +`targets:` declarations. +When an update narrows the intersection, consumer-owned merge-based +hook entries and their ownership record MUST be reconciled under +[req-lk-021](#req-lk-021), while entries without the consumer's own +ownership attribution remain preserved. + +#### 8.5.4 Project-scoped native hook execution + + +**[req-tg-010]** A conforming **consumer** implementation that deploys a +project-scoped hook into a target-native configuration whose hook command may +be launched with a working directory outside the consumer project MUST anchor +the generated command to the consumer project through that target's portable +project-directory environment variable. The command MUST execute successfully +when that variable identifies the consumer project, MUST preserve the hook's +relative path beneath that project, and MUST NOT embed an absolute consumer +checkout path. For Claude project hooks, such a consumer MUST reject a +hook path containing a dollar sign or backtick, because either character can +cause the target shell to reinterpret a path component. + +> **Editorial note.** For Claude project hooks, the portable variable is +> `CLAUDE_PROJECT_DIR` in POSIX commands and `$env:CLAUDE_PROJECT_DIR` in +> PowerShell commands. This requirement permits target-specific command syntax; +> it does not prescribe a shell for other targets. + +#### 8.5.5 Agent Plugins v1 native-lifecycle deployment boundary + + +**[req-tg-011]** A conforming **consumer** implementation MUST treat a +schema-bearing Agent Plugins v1 dependency as opaque at the deployment +boundary unless an applicable target-native lifecycle admits it. Dependency +acquisition, materialization beneath the resolved dependency root, and lock +identity recording MAY occur before that boundary. When the effective target +intersection computed under [req-tg-008](#req-tg-008) does not select an +applicable native lifecycle, the consumer MUST skip deployment of that +dependency and emit one actionable diagnostic, MUST NOT run a legacy primitive +integrator for it, and MUST NOT create target-native registration, settings, +catalog, or ownership state for it. The materialized package and lock state MAY +remain. This skip is +dependency-scoped: ordinary dependencies in the same install MUST remain +eligible for their normal deployment, and a `--dry-run` invocation MUST report +the same per-dependency deployment decision without mutating state. +If an earlier operation registered the dependency but the current effective +target intersection no longer selects that native lifecycle, the consumer MUST +retire the dependency's consumer-owned catalog, activation, and ownership +entries through the rollback unit in [req-tg-013](#req-tg-013). + +> **Editorial note.** This requirement governs the deployment boundary +> only. A machine-verifiable consumer lifecycle now exists: +> [req-tg-013](#req-tg-013) defines native registration for a consumer +> that exposes one. "Applicable" is determined from successful Agent Plugins +> schema parsing, the effective target intersection in +> [req-tg-008](#req-tg-008), and the admission gates referenced by +> [req-tg-013](#req-tg-013), not from whether a host binary happens to be +> installed. This requirement still does not itself define that lifecycle, and +> it does not mandate any preferred-default change. + +#### 8.5.6 Plugin-root hook command resolution + + +**[req-tg-012]** When a conforming **consumer** implementation resolves an +implementation-defined plugin-root placeholder in a hook command, it MUST treat +a placeholder enclosed in matching quotation marks and followed by a forward +slash (`/`) or backslash (`\`) path separator outside the closing quote as +equivalent to the placeholder and path enclosed together in one double-quoted +span, regardless of the source quote character. The generated command MUST +preserve balanced quoting, and any environment-variable expression retained in +that command MUST remain live for target expansion. If any +implementation-defined plugin-root placeholder remains unresolved, the consumer +MUST emit a default-visible diagnostic before the operation returns; it MUST +NOT silently deploy the unresolved command. + +> **Editorial note.** A plugin-root placeholder has the form `${NAME}`; each +> consumer documents the fixed set of names it recognizes for its targets. +> `${PLUGIN_ROOT}` is an illustrative spelling. For example, +> `"${PLUGIN_ROOT}"/hooks/probe.py` normalizes to +> `"${PLUGIN_ROOT}/hooks/probe.py"`. + +#### 8.5.7 Agent Plugins v1 native-lifecycle registration + + +**[req-tg-013]** A conforming **consumer** implementation that exposes a +machine-verifiable native lifecycle for a schema-bearing Agent Plugins v1 +dependency MAY register that dependency with a target-native plugin host when +the acquired and materialized dependency has passed Agent Plugins schema +parsing, the effective target intersection computed under +[req-tg-008](#req-tg-008) selects that host, and the applicable lock-integrity +obligations ([req-lk-013](#req-lk-013), [req-lk-015](#req-lk-015)), +authorized source-plan scan ([req-sc-015](#req-sc-015)), and executable +authorization ([req-sc-009](#req-sc-009), [req-sc-010](#req-sc-010), +[req-sc-011](#req-sc-011), and [req-sc-014](#req-sc-014)) admit the +package. Admission during install, +update, restore, uninstall, and prune MUST NOT locate, invoke, or version-check +the target-native host binary. When the dependency is registered, the consumer +MUST NOT additionally project that package's primitives, so a single dependency +is deployed exactly once. The consumer MUST NOT copy the package into the +target's private plugin state; the registration MUST reference the materialized +package in place beneath the resolved dependency root. A single aggregate +registration per install scope MUST cover both directly declared and +transitively resolved Agent Plugin dependencies that passed the admission +conditions above, without changing their +`depth` and `resolved_by` lockfile fields defined in +[Section 5.2](#52-per-entry-fields). + +A consumer MUST exclude from plugin-name claimant selection every Agent Plugin +dependency that did not pass the admission conditions above. It MUST NOT +register such a dependency. Among admitted dependencies that declare the same +plugin name, a directly declared dependency MUST win over a transitive +dependency. Two admitted claimants at the same precedence MUST cause the +aggregate registration to fail with an actionable diagnostic naming both +claimants. A consumer MUST NOT silently repoint a ledger-recorded owner to a +different admitted transitive claimant. An admitted directly declared claimant +MAY replace a recorded transitive owner under the direct-precedence rule, but +the ownership record and registration MUST update in the same rollback unit. +A post-lifecycle reconciliation after uninstall, prune, or restore MAY +downgrade a residual same-precedence or transitive-repoint collision to an +actionable diagnostic so cleanup can continue, but it MUST omit every ambiguous +or changed-owner entry and MUST NOT commit a new owner for that plugin name. + +The consumer MUST record registration ownership in consumer-owned state. A +consumer that reserves a marketplace identifier and activation-key suffix for +its generated registration MAY treat that namespace as consumer-owned only +while its exact expected directory-marketplace entry is present or being +created. It MUST refuse an existing conflicting entry that uses the reserved +marketplace identifier before treating the activation-key suffix as owned. Its +ownership record is the primary evidence for later reconciliation and removal. +If that record is missing, the consumer MAY re-adopt an existing entry only +when it is exactly the directory-marketplace entry the consumer would generate. +It MAY then reconcile the reserved activation-key suffix from the aggregate +registration. Its conformance statement MUST identify any reserved marketplace +identifier and activation-key suffix. Registration and removal MUST preserve +unrelated host JSON keys and values semantically; stable JSON serialization MAY +reformat the document. Invalid JSON, including JSONC comments, MUST fail closed +before overwrite. Catalog, ownership-record, and settings writes MUST commit as +one rollback unit so a failed write leaves no partial new registration. +Removal MUST retire only entries in that consumer-owned rollback unit and MUST +preserve settings outside the reserved marketplace identifier and activation +suffix. + +> **Editorial note.** "Machine-verifiable" qualifies the consumer lifecycle, +> not the runtime present on an operator's machine. A consumer can qualify +> compatibility at release or build time with a pinned real-host lifecycle +> suite; this implementation uses +> `tests/integration/test_copilot_native_plugin_binary_lifecycle.py`. Operators +> remain responsible for supplying a compatible runtime. In this implementation +> the host is GitHub Copilot CLI, the reserved marketplace identifier is `apm`, +> activation keys use the `@apm` suffix, and the ownership ledger lives under +> `apm_modules`. Manual `*@apm` activation keys are unsupported while the exact +> APM directory-marketplace entry is present because reconciliation owns that +> reserved namespace. This requirement does not prescribe that host, namespace, +> or compatibility version for other consumers. +> A directory-marketplace entry points the host at a generated catalog rooted in +> the materialized dependency directory; it does not copy package content into +> the host's private plugin state. + +#### 8.5.8 User-scoped MCP target selection + + +**[req-tg-014]** A conforming **consumer** implementation that supports a user +installation scope MUST disclose in its conformance statement the user-scope +manifest and lockfile locations and the versioned target-capability declaration +it uses to determine user-scope MCP support. The consumer MUST treat a target +without that declared capability as unsupported. + +When the consumer installs an MCP server into a user scope, it MUST resolve the +effective target selection from the first applicable source in this order: an +explicit target selection; a non-empty user-scope manifest restriction that +does not contain the literal no-restriction sentinel `all`; a configured user +default; then user-scope runtime discovery. Once a source selects one or more +targets, the consumer MUST NOT consult a lower-precedence source. Project-scoped +target-detection signals outside the user scope MUST NOT constrain the discovery +step. A manifest `all` token is treated as no restriction and therefore does +not suppress lower-precedence user-scope defaults or discovery. + +Before creating or modifying the user-scope manifest, lockfile, or target +configuration for the attempted MCP entry, the consumer MUST partition the +selected targets by the declared user-scope MCP capability. If no supported +target remains, it MUST emit an actionable diagnostic and MUST NOT make a +persistent mutation or fall back to discovery. For a mixed set, the supported +subset MUST become the effective target set; the consumer MUST diagnose every +unsupported target, MUST NOT write its target configuration, and MUST NOT fall +back to discovery. If it persists an explicit mixed selection as a user-scope +manifest restriction, it MUST serialize only the supported subset using target +identifiers whose replay selects the same runtimes; it MUST NOT persist an +unsupported member or remap one to a different supported runtime. + +### 8.6 Per-target primitive support (informational) + +The matrix of which primitive types each target supports is +informational and additive: new harness adapters MAY add support +without a spec revision. The current matrix is in the companion +[targets-matrix.md](../../reference/targets-matrix/). + +### 8.7 Conformance requirements (primitives and targets) + +- Consumer: [req-pr-001](#req-pr-001), [req-pr-002](#req-pr-002), + [req-pr-003](#req-pr-003), [req-tg-001](#req-tg-001), + [req-tg-002](#req-tg-002), [req-tg-003](#req-tg-003), + [req-tg-004](#req-tg-004), [req-tg-005](#req-tg-005), + [req-tg-006](#req-tg-006), [req-tg-007](#req-tg-007), + [req-tg-008](#req-tg-008), [req-tg-009](#req-tg-009), + [req-tg-010](#req-tg-010), [req-tg-011](#req-tg-011), + [req-tg-012](#req-tg-012), [req-tg-013](#req-tg-013), + [req-tg-014](#req-tg-014), [req-pr-006](#req-pr-006), + [req-pr-007](#req-pr-007). + +--- + +## 9. Versioning and amendment process + +### 9.1 Spec versioning + +OpenAPM follows the semver discipline at the document level: + +- **0.x** -- editor's drafts. Each minor MAY introduce breaking + changes with the migration window in [Section 9.5](#95-migration-windows-for-consumers). +- **1.0** -- first stable cut. Strictly additive within the 1.x + major. +- **2.x and beyond** -- breaking changes require a major bump. + +### 9.2 Breaking vs. non-breaking change definition + +**Non-breaking** (allowed within a minor): + +- Adding a new OPTIONAL field to manifest, lockfile, or policy. +- Adding a new enum value to a non-conformance-critical enum (for + example a new target name registered in the Target Registry + companion). +- Adding a new conformance test for behaviour already required. +- Making an evaluation deterministic when no normative statement + previously defined it, the specification already named the field + and its purpose, no field is removed, renamed, or retyped, and no + existing fail-closed obligation is relaxed. The Appendix D row for + the amendment MUST name every verdict class that can change. This + does not license changing an evaluation already defined by a + normative statement. + +**Breaking** (requires a minor bump with migration window): + +- Removing or renaming a field. +- Changing the type or required-ness of a field. +- Tightening a SHOULD to a MUST. (Tightening introduces a new + hard-conformance bar; it requires migration even when + most implementations already meet the bar.) +- Loosening a MUST to a SHOULD. +- Promoting a parse-time warning to a parse-time error. +- Bumping `lockfile_version` for non-additive changes. + +### 9.3 Amendment process + +1. An issue is filed in `microsoft/apm` with a `spec/openapm-vN.x` + label. +2. An editor's-draft PR amends + `docs/src/content/docs/specs/openapm-vN.x.md`. +3. A reviewer panel of at minimum two non-author reviewers (one + with implementation experience, one with + consumer/integrator experience) approves. +4. A 14-day public comment period follows panel approval. +5. Merged amendments append to [Appendix D](#appendix-d-revision-history) + with a `req-xxx`-level diff. + +### 9.4 Errata vs. new revision + +**Errata** are clarifications that do not change implementation +behaviour. They are merged inline with an `[Errata YYYY-MM-DD]` +footnote and summarised at the top of [Appendix D](#appendix-d-revision-history). +A new revision (minor bump) is required for any change that +satisfies the breaking-change definition in [Section 9.2](#92-breaking-vs-non-breaking-change-definition). + +### 9.5 Migration windows for consumers + +Breaking changes MUST be announced in the **previous** minor's +revision history (so integrators see the announcement while building +against the still-supported minor). A minimum of **90 days** MUST +elapse between announcement and removal. Consumers SHOULD emit +deprecation warnings during the migration window. + +The marketplace input block (manifest [Section 4.7](#47-marketplace-authoring-block-normative-input)) +is part of the manifest format. The shape of the emitted +`marketplace.json` artifact is governed externally; this +specification tracks upstream changes additively and does not bind +the emitted artifact. + +--- + +## 10. Security considerations + +This section enumerates the attack surfaces this specification +addresses, and maps each to the normative requirement(s) that +mitigate it. Normative registry HTTP wire-level treatment remains +reserved for a future revision, not activated by v0.2.0. + +### 10.1 Dependency confusion + +**Threat.** An adversary publishes a package with the same name as +an internal package on a public registry; the consumer's resolver +fetches the public copy instead of the internal one. + +**Current posture.** Absent an active Governance policy, this revision +has **NO consumer-class mitigation** for dependency confusion: the +unprotected consumer install MUST be assumed vulnerable, and this +specification does not claim otherwise. Mitigations below are +Governance-class controls that an organisation MUST opt into. + +**Mitigations (Governance-class only).** The Governance class's +allow/deny tri-state ([req-pl-005](#req-pl-005), +[req-pl-006](#req-pl-006)) lets an organisation pin acceptable +sources. The `require_pinned_constraint` rule +([req-pl-007](#req-pl-007)) forces the consumer to declare intent +explicitly, surfacing the dependency for review. A possible +`registry_source.allow_non_registry: false` toggle remains reserved +for a future revision; this revision relies on policy review. + +**Consumer-default cache isolation.** Cross-repository cache +substitution is distinct from registry name confusion: a consumer +that keys cached source material by a path prefix or ref alone can +serve bytes from one repository for a different declared repository. +[req-rs-016](#req-rs-016) requires complete minimum repository +identity at every cache layer and forbids that reuse. + +### 10.2 Typosquatting + +**Threat.** A lookalike package name (`acm/security-baseline` instead +of `acme/security-baseline`) lures the consumer into installing a +hostile package. + +**Current posture.** Absent an active Governance policy, this revision +has **NO consumer-class mitigation** for typosquatting. Lookalike +detection, vendor-distance scoring, and registry-side +disambiguation remain reserved for a future revision and are explicitly out of +scope for the consumer in this revision. + +**Mitigations (Governance-class only).** Canonical normalisation +([req-mf-009](#req-mf-009)) collapses cosmetic differences and +makes name comparisons stable for policy authors. +`require_pinned_constraint` ([req-pl-007](#req-pl-007)) forces +explicit refs, raising review value. Allow/deny lists +([req-pl-005](#req-pl-005), [req-pl-006](#req-pl-006)) gate the +acceptable name space. + +### 10.3 Token leakage across hosts + +**Threat.** A credential issued for `github.com` is forwarded to +`evil.example.com` during a cross-host clone, leaking the token. + +**Mitigations.** + + +**[req-sc-003]** A conforming **consumer** implementation MUST +resolve credentials per host class (as defined in +[Section 3](#3-terminology) and as alias-extended via +[req-sc-005](#req-sc-005)), and MUST NOT forward a credential +resolved for one host class to a request targeting another host +class. Credential scope MUST be observable in the consumer's +diagnostic surface. When a fetch follows an HTTP redirect (3xx) +whose target hostname classifies into a different host class than +the originating request per [req-sc-005](#req-sc-005), the consumer +MUST drop the originating Authorization header (and any other +credential material attached for the originating host class) before +issuing the redirected request. Credentials for the destination +host class MAY be re-resolved per this requirement. + + +**[req-sc-013]** A conforming **consumer** implementation that permits +operator configuration to assign a literal authority hostname to a host +class: (a) it MUST select exactly one effective host class before credential +resolution. For this requirement, a **configuration signal** is any +manifest declaration or implementation-specific operator setting that +binds a hostname to a host class. (b) If two or more configuration signals +claim the same literal authority hostname, the precedence MUST be +deterministic and documented in the consumer's +[conformance statement](#112-how-to-claim-conformance). + +For each request and transport child process spawned to fetch or validate +the dependency (for example a git client or credential helper), the +consumer: (c) it MUST resolve, attach, and expose only credential material +belonging to the selected host class; and (d) credential material belonging +to an unselected class MUST NOT be resolved, attached, or inherited by that +child process. The consumer MUST actively suppress ambient credential +material (for example environment variables) that the child process would +otherwise inherit from a parent scope. Literal credential values remain +subject to the redaction obligation of [req-sc-007](#req-sc-007); source +descriptors MAY appear in the diagnostic surface required by +[req-sc-003](#req-sc-003). + +(e) An explicit non-default port (using the protocol-default equivalences +in [req-rs-016](#req-rs-016) item (2)) in the dependency reference MUST +remain part of both the transport endpoint and credential scope. The port +narrows credential lookup within the already-selected host class; it does +not create a distinct host class. + + +**[req-sc-005]** A conforming **consumer** implementation that +classifies two distinct hostnames as the same host class for the +purposes of credential reuse MUST do so on the basis of (a) +identical eTLD+1 per the Public Suffix List +([https://publicsuffix.org](https://publicsuffix.org)), or (b) an +explicit `aliases:` entry in the project's `apm.yml` +`registries:` block (see [Section 4.2.3](#423-registries) and +[req-sc-006](#req-sc-006)). Implementations MUST NOT collapse two +hostnames onto the same host class on any other basis (such as +DNS CNAME chains, TLS SAN entries, or shared HTTP redirects). +A host-class assignment produced by a configuration signal exercised +under [req-sc-013](#req-sc-013) is not subject to this prohibition. + + +**[req-sc-007]** A conforming **consumer** implementation MUST +redact credential material (tokens, basic-auth passwords, bearer +strings) so that such material MUST NOT appear in any user-facing +diagnostic, log, error message, packed bundle, lockfile, or +persisted audit record. The diagnostic that reveals "credential X +was used for host Y" MUST identify the credential by source +descriptor (for example `GITHUB_APM_PAT environment variable`), +not by literal value. The Producer toolchain MUST refuse to pack +any file whose path matches the configurable secret-pattern set +(default patterns: `.env`, `.env.*`, `*.pem`, `*.key`, `id_rsa`, +`id_ed25519`); the pattern set MAY be extended via policy. See +also the broader token-leakage mitigation row in +[Section 10.11](#1011-summary-table). + + +**[req-sc-008]** A conforming **consumer** implementation SHOULD +refuse to attach a credential to a git-over-HTTP fetch whose URL +scheme is not `https://`, unless the target host is the loopback +address (`127.0.0.0/8`, `::1`) or the target registry is declared +with `insecure: true` per [req-sc-006](#req-sc-006). + +### 10.4 Lockfile tampering + +**Threat.** An adversary edits `apm.lock.yaml` to swap a commit SHA +or a deployed-file hash and ship a malicious payload that still +"passes" a naive integrity check. + +**Mitigations.** + + +**[req-sc-001]** A conforming **consumer** implementation MUST +compute and record a SHA-256 content hash for every deployed file +(per [req-lk-012](#req-lk-012)) and MUST re-verify those hashes on +audit. Files present in `deployed_files` whose on-disk hash does +not match the recorded hash MUST be reported as a content-integrity +violation. + +In addition, [req-lk-013](#req-lk-013) verifies registry archive +bytes before extraction, and [req-lk-017](#req-lk-017) re-verifies +deployed-file hashes on every frozen install. The combination +prevents an attacker from tampering with installed files without +detection. + +### 10.5 Registry impersonation + +**Threat.** DNS or MITM redirection points a registry URL at an +attacker-controlled host serving a manipulated archive. + +**Mitigation.** [req-lk-013](#req-lk-013) anchors trust in the +archive's SHA-256 (per the hash envelope at +[req-lk-016](#req-lk-016)), not the URL: a tampered archive fails +closed before extraction. The mirror-tolerance rule +([req-rs-009](#req-rs-009)) preserves this property: a mirror MAY +serve the bytes, but the bytes MUST still hash to the lockfile's +recorded `resolved_hash`. Registry-class implementations +participating in this trust chain MUST satisfy +[req-rg-001](#req-rg-001). A normative TLS-only requirement +on the registry HTTP wire remains reserved for a future revision in +[Appendix B](#appendix-b-registry-http-api-reserved); it is not activated here. + +In addition: + + +**[req-sc-004]** A conforming **consumer** implementation MUST +constrain registry archive extraction so that (a) the archive +content-type is `application/gzip` over a tar payload (`tar.gz`); +implementations MUST reject `application/zip` and any other +archive container in this revision; (b) the uncompressed archive size MUST +NOT exceed a configurable cap whose default value is **100 MB**; +and (c) the number of entries in the archive MUST NOT exceed a +configurable cap whose default value is **10,000**. Violations MUST +fail closed before extraction proceeds. + +### 10.6 Malicious package execution at install time + +**Threat.** A hostile package's `scripts:` block executes during +install. + +**Mitigation.** This specification does **not** authorise +`apm install` to execute any `scripts:` entry. The producer-side +`scripts:` block is a named-entry registry consumed by an explicit +user invocation (such as `apm run `). Governance MAY further +forbid `scripts:` declarations via `manifest.scripts: deny`. The +absence of an install-time execution path is the load-bearing +mitigation; the policy block is defence in depth. + +### 10.7 Unverified content cleanup (file integrators) + +**Threat.** A hostile transitive dependency claims authority over +files outside its real deployment footprint, causing the cleanup +logic to remove files belonging to another dependency or to the +project itself. + +**Mitigations.** [req-tg-002](#req-tg-002) constrains each target +to its registered deploy root(s). The self-entry isolation in +[Section 5.3](#53-self-entry-semantics) prevents the cleanup logic +of one dependency from claiming the project's own files. Orphan +detection MUST scope per-dependency, not globally. +For supported dependency aliases, [req-mf-025](#req-mf-025) constrains +destinations and records canonical placement for later cleanup. Containment +alone does not establish ownership or authorize deletion of existing content. + +### 10.8 Policy bypass via crafted manifest + +**Threat.** A consumer manifest exploits parser ambiguity (an +unknown key, a malformed `extends:` chain) to silently skip the +governance gate. + +**Mitigations.** [req-pl-009](#req-pl-009) makes unknown policy +keys a warning, not a silent acceptance, and preserves them as +`x-*` extensions. [req-pl-010](#req-pl-010) fails closed on fetch +failure when configured. [req-pl-002](#req-pl-002) blocks before +disk write. [req-pl-003](#req-pl-003) caps `extends:` depth to +thwart amplification attacks. [req-pl-018](#req-pl-018) prevents a +case-variant repository spelling from bypassing an allow-list or +deny-list when resolution treats both spellings as one package +identity. On a case-sensitive source, differently cased repository +paths remain distinct and policy authors need to enumerate the spellings +they intend to deny. The same normalization widens `dependencies.allow` +matching on a case-insensitive source, so an upgrade can admit a +case-variant spelling that previously missed. Clause (d) of +[req-pl-018](#req-pl-018) leaves segments at and after recursive-glob +ambiguity byte-exact, including on a case-insensitive source. The +match subject is host-blind, so one policy entry governs the same +repository path on every reachable host; governance authors relying +on host separation need an independent host-level control. + +### 10.9 Archive path-traversal (zip-slip / symlink escape) + +**Threat.** A crafted tarball or zip with `..` segments, absolute +paths, or symlinks writes files outside the extraction root. + +**Mitigation.** + + +**[req-sc-002]** A conforming **consumer** implementation MUST +reject any archive entry whose extracted path would contain `..` +segments, would be absolute, or would be a symbolic or hard link. +Extraction MUST fail closed on the first such entry; partial +extractions MUST be cleaned up. The archive container, size, and +entry-count limits of [req-sc-004](#req-sc-004) MUST be enforced +in addition. + +### 10.10 Hash-algorithm downgrade + +**Threat.** A consumer's `policy.hash_algorithm` accepts a weak +digest (MD5, SHA-1) and an attacker exploits collision weakness to +serve a manipulated policy that matches the recorded digest. + +**Mitigation.** [req-mf-018](#req-mf-018) restricts the allowed +algorithms to `sha256`, `sha384`, and `sha512`, rejecting weaker +choices at parse time. The lockfile hash envelope +([req-lk-016](#req-lk-016)) makes the digest algorithm explicit on +every stored hash, foreclosing algorithm-ambiguity attacks. + +### 10.11 Summary table + +| # | Attack surface | Mitigation requirement(s) | Posture | +|---|---------------------------------------------|--------------------------------------------------------------------|-------------------| +| 1 | Dependency confusion | [req-pl-005](#req-pl-005), [req-pl-006](#req-pl-006), [req-pl-007](#req-pl-007) | Governance-only | +| 2 | Typosquatting | [req-mf-009](#req-mf-009), [req-pl-005](#req-pl-005), [req-pl-007](#req-pl-007) | Governance-only | +| 3 | Token leakage across hosts | [req-sc-003](#req-sc-003), [req-sc-005](#req-sc-005), [req-sc-007](#req-sc-007), [req-sc-008](#req-sc-008), [req-sc-013](#req-sc-013) | Consumer-default | +| 4 | Lockfile tampering | [req-lk-012](#req-lk-012), [req-lk-013](#req-lk-013), [req-lk-016](#req-lk-016), [req-lk-017](#req-lk-017), [req-sc-001](#req-sc-001) | Consumer-default | +| 5 | Registry impersonation | [req-lk-013](#req-lk-013), [req-rs-009](#req-rs-009), [req-sc-004](#req-sc-004); TLS-only wire rule remains deferred | Consumer-default | +| 6 | Malicious package execution at install time | No install-time execution path; [req-pl-006](#req-pl-006) defence | Consumer-default | +| 7 | Unverified content cleanup | [req-tg-002](#req-tg-002), [req-lk-020](#req-lk-020), [req-lk-021](#req-lk-021); [req-mf-025](#req-mf-025) for supported aliases; self-entry isolation | Consumer-default | +| 8 | Policy bypass via crafted manifest | [req-pl-002](#req-pl-002), [req-pl-009](#req-pl-009), [req-pl-010](#req-pl-010), [req-pl-018](#req-pl-018) | Governance-only | +| 9 | Archive path-traversal | [req-sc-002](#req-sc-002), [req-sc-004](#req-sc-004) | Consumer-default | +| 10| Hash-algorithm downgrade | [req-mf-018](#req-mf-018), [req-lk-016](#req-lk-016) | Consumer-default | +| 11| Unauthorised executable primitive deployment | [req-sc-009](#req-sc-009) | Consumer-default | +| 12| Approval grant propagation via VCS | [req-sc-010](#req-sc-010) | Consumer-default | +| 13| Org executable denial bypassed by project/user grant | [req-sc-011](#req-sc-011) | Consumer-default | +| 14| Required-package audit false-positive on withheld executable | [req-sc-012](#req-sc-012) | Consumer-default | +| 15| Cross-repository cache substitution | [req-rs-016](#req-rs-016) | Consumer-default | +| 16| Silent capability-scope widening via lossy target conversion | [req-tg-006](#req-tg-006); default-visible conversion diagnostic | Consumer-default | +| 17| Cross-target primitive deployment | [req-tg-008](#req-tg-008), [req-lk-021](#req-lk-021) | Consumer-default | +| 18| Case-collision materialization confusion | [req-lk-022](#req-lk-022), [req-rs-016](#req-rs-016) | Consumer-default | +| 19| Executable deployment in non-interactive contexts | [req-sc-014](#req-sc-014) | Consumer-default | +| 20| Source-only or symlinked package content materialization | [req-sc-015](#req-sc-015) | Consumer-default | +| 21| Native plugin namespace collision or ownership-ledger loss | [req-tg-013](#req-tg-013) | Consumer-default | +| 22| Remote-to-local source substitution or internal local-symlink escape | [req-mf-016](#req-mf-016); source admission and acquisition, distinct from target source-plan controls | Consumer-default | + +Source admission in [req-mf-016](#req-mf-016) determines whether and +where dependency content may be acquired. It is distinct from the +post-authorization target source-file plan in [req-sc-015](#req-sc-015). + +### 10.12 Publisher provenance and attestations (reserved) + +Publisher provenance (cryptographic attestations binding a +specific package version to a specific publisher identity) is +**out of scope and reserved for a future revision**. The +lockfile's `attestations:` field (per +[req-lk-001](#req-lk-001)) and the producer-side tag-signing +SHOULD ([req-pr-005](#req-pr-005)) are reserved hooks for this +future surface. A future surface will define: in-toto / SLSA +provenance binding format; sigstore verification semantics; +Governance `policy.dependencies.require_attestation` enforcement +modes; and the registry HTTP wire envelope (alongside +[Appendix B](#appendix-b-registry-http-api-reserved)). + +The declaring-source context in [req-mf-016](#req-mf-016) is not +cryptographic publisher provenance and does not activate this reservation. + +:::note[Planned] +**Trust boundary (informative).** Digest comparisons assume a trusted expected +digest or lockfile; they do not authenticate simultaneous replacement of +record and payload. The reserved attestation subject comprises canonical +package/source identity and version, the applicable archive or Git-tree +content digest, and publisher identity. Trust-root selection, verification +policy, and an interoperable envelope require separately scoped design. +Attestation verification and enforcement remain reserved. Trusted records +do not repair the distinct Git-tree encoding defect in +[Section 5.6.4](#564-git-source-tree-integrity-hash). +::: + +### 10.13 Executable primitive approval gate + +**Threat.** A dependency package deploys executable code -- +hooks, bin executables, MCP server configurations, or canvas +extensions -- that runs on the developer's machine without +explicit consent. + +**Mitigations.** + + +**[req-sc-009]** A conforming **consumer** implementation MUST, +when the consuming project's `apm.yml` contains an `allowExecutables` +block, deny deployment of any executable primitive (hooks, bin +executables, MCP server configurations, and canvas extensions) +from a dependency package unless that package is explicitly listed +in the effective approval set for the corresponding executable type. +A consumer MUST fail closed when the `allowExecutables` block is +present but the package is absent from the approval set: the +primitive MUST NOT be deployed. + + +**[req-sc-010]** A conforming **consumer** implementation that +provides an interactive approval mechanism for executable primitives +MUST persist per-user approval decisions in a location that is +isolated from the project manifest (`apm.yml`) and is not tracked +by version control alongside project files by default. The consumer +MUST NOT write interactive approval decisions into the project +`apm.yml`, so that one developer's approval cannot propagate +implicitly to other developers who clone or share the project. + +### 10.14 Executable trust precedence and audit fidelity + +**Threat.** Two failure modes defeat centralized executable +governance. First, a project-level or user-level approval re-enables +an executable primitive that an organization policy has denied, or +the install-time gate and the post-install audit reach different +trust conclusions for the same primitive (a split-brain that lets a +denied primitive read as trusted, or the reverse). Second, a +governance requirement mandating that a package be present is +reported as unmet merely because that package's executable primitives +were withheld from deployment, making it impossible to both mandate a +package and let a consumer withhold its executables. + +**Mitigations.** + + +**[req-sc-011]** A conforming **consumer** implementation MUST resolve +every executable-primitive trust decision through a single deny-wins +precedence relation in which an organization policy denial (an +`executables.deny` entry or `executables.deny_all` in an applicable +`apm-policy.yml` per [Section 6](#6-policy-format-apm-policyyml)) +overrides any project-level or user-level grant for the same package +and executable type. A consumer MUST reach the identical allow-or-deny +outcome for identical inputs whether the decision gates deployment at +install time or classifies an already-installed primitive at audit +time. A consumer MUST NOT allow a project `apm.yml` grant or a +user-local approval to re-enable an executable primitive that an +applicable organization policy denies. + + +**[req-sc-012]** A conforming **consumer** implementation that +evaluates a governance requirement mandating the presence of a package +(per [Section 6](#6-policy-format-apm-policyyml)) MUST determine +satisfaction of that requirement from the presence of the package in +the resolved lockfile, and MUST NOT condition it on whether that +package's executable primitives were deployed. When a required package +is present but one or more of its executable primitives are withheld +from deployment by the trust resolution of +[req-sc-011](#req-sc-011), a consumer MUST treat the presence +requirement as satisfied and MUST surface each withheld executable as +a diagnostic signal distinct from any missing-package violation. + +### 10.15 Per-invocation executable consent + +**Threat.** An operator runs `apm install` in a non-interactive context +(piped output, CI pipeline, `--frozen` mode) and a marketplace plugin +deploys `bin/` executables to the developer tool's PATH without any +visible consent signal, because the per-invocation warning is swallowed +by log redirection. + +**Mitigation.** + + +**[req-sc-014]** A conforming **consumer** implementation that supports +a per-invocation consent flag for `bin/` executable deployment MUST deny +that deployment by default when its standard output is not connected to a +terminal (i.e., the output stream is not a TTY), unless the operator has +explicitly opted in for that invocation. An explicit per-invocation opt-in +(for example `--trust-bin`) overrides the non-interactive default and +permits deployment. An explicit per-invocation opt-out (for example +`--no-trust-bin`) overrides the non-interactive default and denies +deployment even when the output IS a terminal. The `allowExecutables` +policy gate [req-sc-009](#req-sc-009) is evaluated before per-invocation +consent and always takes precedence: a policy-level denial cannot be +overridden by a per-invocation opt-in. + +### 10.16 Authorized source-plan materialization + +**Threat.** A package can hide hostile content in a source-only file or a +symlink entry, then reach a primitive target through a lifecycle path that +does not reuse the authorization and scan decision made for ordinary install. + +**Mitigation.** + + +**[req-sc-015]** A conforming **consumer** implementation MUST derive exactly +one authorized source-file set after target, package-subset, and executable +authorization for every primitive-materialization lifecycle, including +install and re-integration after uninstall. The set MUST exclude symlink +files and MUST NOT traverse symlinked directories. The consumer MUST apply +any pre-deployment content security scan to the full set before any +source-derived target write and MUST materialize primitive files only from that +same set; each primitive-integrator materialization path MUST consume the +canonical set rather than derive a second classifier. If a selected file +violates a blocking scan policy, the consumer MUST reject that materialization +before writing a source-derived target file unless the operator explicitly +forces the install. The consumer MUST NOT scan or materialize a +source-only file merely because it is present in the package tree. The +executable authorization used to derive the set remains governed by +[req-sc-009](#req-sc-009). + +### 10.17 Native plugin namespace and ownership recovery + +**Threat.** A foreign marketplace entry captures a consumer's reserved +identifier, or loss of the consumer-owned ledger causes registration cleanup +to overwrite unrelated host settings or retain stale activation keys. + +**Mitigation.** [req-tg-013](#req-tg-013) permits namespace recovery only from +the exact directory-marketplace entry the consumer would generate, refuses a +foreign collision, reconciles only the declared reserved activation-key +suffix, preserves unrelated JSON values semantically, and commits catalog, +ledger, and settings writes as one rollback unit. + +--- + +## 11. Conformance + +### 11.1 Conformance classes (normative) + +This specification defines four conformance classes; this section +is the **sole normative home** for them. The forward pointer in +[Section 2](#2-conventions) is editorial. + +| Class | Role | +|--------------|---------------------------------------------------------------------------------------| +| Producer | Emits a conforming `apm.yml`; optionally emits a conforming `apm.lock.yaml`. Conformance hooks: tag-release contract ([req-pr-004](#req-pr-004), [req-pr-005](#req-pr-005)). | +| Consumer | Parses `apm.yml`, resolves dependencies per [Section 7](#7-dependency-resolution), writes `apm.lock.yaml`, deploys primitives per [Section 8](#8-primitive-type-system-and-target-matrix). | +| Registry | One operative trust-anchor obligation: [req-rg-001](#req-rg-001). Broader HTTP wire conformance remains reserved for a future revision. | +| Governance | Parses `apm-policy.yml`, evaluates per [Section 6](#6-policy-format-apm-policyyml), gates a Consumer install. | + +An implementation MAY claim more than one class. A toolchain +component that packs a project (the "producer toolchain") typically +acts as both Producer (it writes the manifest) and single-tenant +Consumer (it validates the manifest and writes a lockfile for its +own pack). + +Section-level conformance summaries +([Section 4.9](#49-conformance-requirements-manifest), +[Section 5.7](#57-conformance-requirements-lockfile), +[Section 6.9](#69-conformance-requirements-governance), +[Section 7.11](#711-conformance-requirements-resolution), +[Section 8.7](#87-conformance-requirements-primitives-and-targets)) +are reader-aids for existing role applicability. Appendix C and the +informative inventory record primary index classification, not exclusive +role applicability. Appendix C takes precedence over section summaries on +primary classification; it does not exempt a secondary role expressly bound +by the requirement body. Cross-role references enumerate existing clauses, +not additional requirements. + +### 11.2 How to claim conformance + +An implementation claiming OpenAPM v0.2.0 conformance MUST publish a +conformance statement identifying: + +1. Which conformance class(es) it claims. +2. The exact revision of the specification it conforms to (`v0.2.0`). +3. The list of OPTIONAL features it implements. +4. Any limitations or non-conformance points, with rationale. +5. Any additional conformance-statement content required by a specific + requirement, including reserved namespace disclosure under + [req-tg-013](#req-tg-013) and fixture citations under + [req-cf-002](#req-cf-002). +6. If it claims the Governance class, or documents a case-insensitive + host under [req-rs-016](#req-rs-016), the repository-coordinate + case rule for every such host. Registry sources are + case-insensitive under [req-rs-016](#req-rs-016) and + [req-pl-018](#req-pl-018); the conformance statement records that + fixed rule rather than choosing it. The declared host rule MUST + agree across repository identity and policy matching. In this revision this + information is a named prose section; a machine-readable carrier is + reserved in [Section 12.6](#126-machine-readable-conformance-manifest-reserved). + +**Foundation assessment status (informative).** This draft and its +informative requirement inventory do not assert that the reference CLI +satisfies the requirements above. +[microsoft/apm#2919](https://github.com/microsoft/apm/pull/2919) owns complete +executable bindings, fresh assessment, and exact artifact/manifest fingerprints. +Imported alias-safety coverage is not a v0.2.0 assessment or evidence of +publication or ratification. Static references and schema checks are not +runtime conformance. + +The prior assessment disclosed an inherited gap: the reference CLI's bare +content audit uses source-derived drift replay rather than the stored-hash +baseline required by [req-lk-017](#req-lk-017)'s unqualified audit obligation; +stored-hash and full-SHA consistency baselines are exercised in CI/conformance +audit. Full Consumer conformance in bare audit mode is not claimed here. +Prior source-only evidence also reports that local acquisition correctly +dereferences an admitted internal resource symlink, but inherited replay +from its original source representation can falsely report the deployed +regular file as orphaned during unchanged CI audit. That is a replay +limitation, not evidence of an escape, external-file read, or security +bypass. This foundation does not rerun or repair those behaviors. + +The retained schema and Git-tree evidence limits are described in +[Appendix A](#appendix-a-normative-json-schemas-inline) and +[Section 5.6.4](#564-git-source-tree-integrity-hash). Controlled native +snapshots do not establish a successful native install/audit round trip; +no new native scratch backend or hosted-runtime evidence is supplied here. +These disclosures are not waivers of any normative obligation. + +### 11.3 Enumerated requirements by class + +#### 11.3.1 Producer + +[req-mf-001](#req-mf-001), [req-mf-002](#req-mf-002), +[req-mf-003](#req-mf-003), [req-mf-004](#req-mf-004), +[req-mf-005](#req-mf-005), [req-mf-014](#req-mf-014), +[req-mf-015](#req-mf-015), [req-mf-017](#req-mf-017), +[req-mf-021](#req-mf-021), [req-ext-002](#req-ext-002), +[req-pr-004](#req-pr-004), [req-pr-005](#req-pr-005) (SHOULD). + +Also applicable: [req-sc-007](#req-sc-007), limited to the existing Producer +secret-pattern packing refusal clause; [req-cf-002](#req-cf-002), for the +existing Producer conformance-claim duties. + +#### 11.3.2 Consumer + +[req-mf-001](#req-mf-001) (existing Consumer mapping-validation clause), +[req-mf-004](#req-mf-004) (SHOULD; existing Consumer version-validation +diagnostic clause only), +[req-mf-006](#req-mf-006), [req-mf-007](#req-mf-007), +[req-mf-008](#req-mf-008), [req-mf-009](#req-mf-009), +[req-mf-010](#req-mf-010), [req-mf-011](#req-mf-011), +[req-mf-012](#req-mf-012), [req-mf-013](#req-mf-013), +[req-mf-016](#req-mf-016), [req-mf-018](#req-mf-018), +[req-mf-019](#req-mf-019), [req-mf-020](#req-mf-020), +[req-mf-021](#req-mf-021), [req-mf-022](#req-mf-022), +[req-mf-023](#req-mf-023), [req-mf-024](#req-mf-024), +[req-mf-025](#req-mf-025), +[req-ext-001](#req-ext-001), +[req-lk-001](#req-lk-001), [req-lk-002](#req-lk-002), +[req-lk-003](#req-lk-003), [req-lk-004](#req-lk-004), +[req-lk-005](#req-lk-005), [req-lk-006](#req-lk-006), +[req-lk-007](#req-lk-007) (SHOULD), [req-lk-008](#req-lk-008), +[req-lk-009](#req-lk-009), [req-lk-010](#req-lk-010), +[req-lk-011](#req-lk-011), [req-lk-012](#req-lk-012), +[req-lk-013](#req-lk-013), [req-lk-014](#req-lk-014), +[req-lk-015](#req-lk-015), [req-lk-016](#req-lk-016), +[req-lk-017](#req-lk-017), [req-lk-018](#req-lk-018) (SHOULD), +[req-lk-019](#req-lk-019), [req-lk-020](#req-lk-020), +[req-lk-021](#req-lk-021), [req-lk-022](#req-lk-022), +[req-lk-023](#req-lk-023), +[req-rs-001](#req-rs-001), [req-rs-002](#req-rs-002), +[req-rs-003](#req-rs-003), [req-rs-004](#req-rs-004), +[req-rs-005](#req-rs-005), [req-rs-006](#req-rs-006), +[req-rs-007](#req-rs-007), [req-rs-008](#req-rs-008), +[req-rs-009](#req-rs-009), [req-rs-010](#req-rs-010), +[req-rs-011](#req-rs-011), [req-rs-012](#req-rs-012), +[req-rs-013](#req-rs-013), [req-rs-014](#req-rs-014), +[req-rs-015](#req-rs-015), [req-rs-016](#req-rs-016), +[req-rs-017](#req-rs-017), +[req-pr-001](#req-pr-001), [req-pr-002](#req-pr-002), +[req-pr-003](#req-pr-003), [req-tg-001](#req-tg-001), +[req-pr-006](#req-pr-006), [req-pr-007](#req-pr-007), +[req-tg-002](#req-tg-002), [req-tg-003](#req-tg-003), +[req-tg-004](#req-tg-004), [req-tg-005](#req-tg-005), +[req-tg-006](#req-tg-006), [req-tg-007](#req-tg-007), +[req-tg-008](#req-tg-008), [req-tg-009](#req-tg-009), +[req-tg-010](#req-tg-010), [req-tg-011](#req-tg-011), +[req-tg-012](#req-tg-012), [req-tg-013](#req-tg-013), +[req-tg-014](#req-tg-014), +[req-sc-001](#req-sc-001), +[req-sc-002](#req-sc-002), [req-sc-003](#req-sc-003), +[req-sc-004](#req-sc-004), [req-sc-005](#req-sc-005), +[req-sc-006](#req-sc-006), [req-sc-007](#req-sc-007), +[req-sc-008](#req-sc-008) (SHOULD), [req-sc-009](#req-sc-009), +[req-sc-010](#req-sc-010), [req-sc-011](#req-sc-011), +[req-sc-012](#req-sc-012), [req-sc-013](#req-sc-013), +[req-sc-014](#req-sc-014), [req-sc-015](#req-sc-015), +[req-cf-001](#req-cf-001), +[req-cf-002](#req-cf-002). + +#### 11.3.3 Registry + + +**[req-rg-001]** A conforming **Registry** implementation +(whose broader wire contract remains reserved) MUST serve archive bytes +such that the SHA-256 of those bytes equals the digest the +Registry advertises for the version, and MUST NOT mutate previously +published `(name, version)` bytes. When a Registry receives a +publish request for an `(name, version)` it has previously served, +the Registry MUST either (a) reject the publish request with a +diagnostic identifying the existing version, or (b) accept it +ONLY if the submitted archive bytes are byte-identical to the +previously-served bytes (idempotent republish). A Registry MUST +NOT replace the bytes of a previously-served `(name, version)` +under any circumstance. This is the trust anchor on which +[req-lk-013](#req-lk-013) and [req-rs-009](#req-rs-009) depend; +the surrounding HTTP wire envelope remains reserved for a future revision. + +#### 11.3.4 Governance + +[req-pl-001](#req-pl-001), [req-pl-002](#req-pl-002), +[req-pl-003](#req-pl-003), [req-pl-004](#req-pl-004), +[req-pl-005](#req-pl-005), [req-pl-006](#req-pl-006), +[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-015](#req-pl-015), [req-pl-016](#req-pl-016), +[req-pl-017](#req-pl-017), [req-pl-018](#req-pl-018). + +### 11.4 Worked conformance examples (informative) + +#### 11.4.1 Producer example + +A minimal artifact a Producer emits: + +```yaml +# apm.yml +name: contoso/security-baseline +version: "2.3.1" +description: Security baseline skills and instructions for contoso projects. +license: MIT +target: [copilot, claude] + +dependencies: + apm: + - contoso/common-prompts#^1.0.0 +``` + +This artifact satisfies [req-mf-001](#req-mf-001), +[req-mf-002](#req-mf-002), [req-mf-003](#req-mf-003), +[req-mf-004](#req-mf-004), [req-mf-005](#req-mf-005). + +#### 11.4.2 Consumer example + +A Consumer reading the manifest above produces the lockfile: + +```yaml +lockfile_version: "2" +apm_version: "0.7.0" +dependencies: + - repo_url: github.com/contoso/common-prompts + resolved_commit: "a1b2c3d4e5f6789012345678901234567890abcd" + resolved_ref: "^1.0.0" + constraint: "^1.0.0" + resolved_tag: v1.4.2 + resolved_at: "2026-05-10T20:14:00+00:00" + tree_sha256: "sha256:0102030405060708091011121314151617181920212223242526272829303132" + depth: 1 + deployed_files: + - .github/prompts/review.prompt.md + deployed_file_hashes: + .github/prompts/review.prompt.md: "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" +``` + +This artifact satisfies [req-lk-001](#req-lk-001), +[req-lk-002](#req-lk-002), [req-lk-003](#req-lk-003), +[req-lk-008](#req-lk-008), [req-lk-011](#req-lk-011), +[req-lk-012](#req-lk-012), [req-lk-015](#req-lk-015), +[req-lk-016](#req-lk-016), and (via the resolution that produced +it) [req-rs-001](#req-rs-001), [req-rs-002](#req-rs-002). + +#### 11.4.3 Governance example + +The following policy chain is evaluated by a Governance +implementation; an install that resolves to the lockfile in +[Section 11.4.2](#1142-consumer-example) is accepted (no +unbounded direct deps, dependency in `contoso/*` allow-list) and is +written to disk: + +```yaml +name: contoso-baseline +enforcement: block +fetch_failure: block +dependencies: + allow: + - contoso/* + require_pinned_constraint: true + max_depth: 25 +``` + +This evaluation exercises [req-pl-002](#req-pl-002), +[req-pl-005](#req-pl-005), [req-pl-007](#req-pl-007), +[req-pl-008](#req-pl-008). + +#### 11.4.4 Registry example + +Wire-format normativity remains reserved for a future revision. A Registry claiming +conformance against this revision's trust-anchor requirement +([req-rg-001](#req-rg-001)) MUST publish an addendum statement +enumerating the immutability guarantees and the digest algorithm +served. + +--- + +## 12. Conformance test methodology + +### 12.1 Statement IDs + +Every normative MUST, SHOULD, or MAY statement in this document +carries a stable `id="req-xxx"` anchor immediately preceding the +statement. These IDs are the contract between the specification and +its conformance suite: the test suite cites IDs, the specification +publishes them. + +### 12.2 Hybrid binding (statement-anchored + fixture-anchored) + +This specification adopts a **hybrid** conformance binding: + +- Statement-anchored: every MUST/SHOULD/MAY carries a `req-xxx` ID. + Conformance-suite tests reference the ID either in a docstring or + via a pytest marker (`@pytest.mark.req("req-mf-005")`). +- Fixture-anchored: a subset of requirements that lend themselves to + black-box file-in / file-out testing (round-trip, malicious + archive, merge-table, semver dialect) MUST additionally carry a + fixture directory under `tests/fixtures/spec-conformance/`. The + seed fixture tree shipped at spec publication is enumerated in + [Section 12.4](#124-fixture-layout-informative). + +### 12.3 CI binding + +A single CI job named `Spec conformance` is RECOMMENDED. The job: + +1. Treats the HTML requirement anchors (``) in the + spec body as the canonical statement list, and treats both the + informative machine-readable manifest at + [`docs/public/specs/manifests/openapm-v0.2.requirements.yml`](/apm/specs/manifests/openapm-v0.2.requirements.yml) + (see [Section 12.6](#126-machine-readable-conformance-manifest-reserved)) + and the [Appendix C](#appendix-c-index-of-normative-statements) + table as derived projections that MUST agree with the canonical + anchors. The manifest remains informative in this revision. +2. Walks the conformance suite for ID references (docstrings, + markers, or fixture directory names) and builds the set of + referenced IDs. +3. Fails if any declared ID has zero references (orphan in the + spec) or any referenced ID is not declared (orphan in the + tests). +4. As a guard against drift in the non-normative companions, the + same job MAY fail when any `MUST`, `SHOULD`, or `MAY` token + appears in `docs/src/content/docs/reference/**` (the companion + pages SHOULD NOT carry normative keywords). + + +**[req-cf-002]** A **Consumer** or **Producer** claiming OpenAPM +v0.2.0 conformance MUST publish a conformance statement (see +[Section 11.2](#112-how-to-claim-conformance)) that cites the test +invocation exercising every `req-XXX` statement in its declared +class against the seed fixture tree under +`tests/fixtures/spec-conformance/`. The conformance statement MUST +list, for each `req-XXX` in scope, the fixture path and the +assertion that exercises it. + +### 12.4 Fixture layout (informative) + +The conformance seed fixture tree shipped with this specification +lives at `tests/fixtures/spec-conformance/`. Concrete on-disk +files: + +``` +tests/fixtures/spec-conformance/ + README.md + manifest/ + valid-minimal.yml + invalid-missing-name.yml + invalid-no-source-key.yml + x-extension-roundtrip.yml + lockfile/ + materialization-sort-exclusion.yml + v1-git-only.yml + v2-with-registry.yml + round-trip-unknown-fields.yml + policy/ + valid-extends.yml + invalid-extends-cycle.yml + resolution/ + semver-dialect.json + source-plan/ + req-sc-015.json +``` + +Conformance-suite expansion (additional fixtures for archive +path-traversal, merge-table cases, etc.) tracks here in subsequent +revisions; the seed set above is the minimum that +implementations can run against immediately. + +The lockfile fixture `materialization-sort-exclusion.yml` exercises +`materialization_repo_url` sort-exclusion while +`v1-git-only.yml` exercises transactional spelling migration and +collision refusal through the [req-lk-022](#req-lk-022) conformance +oracles. + +The `source-plan/req-sc-015.json` oracle describes selected materialized +content, source-only content, and excluded symlink files and directories +for [req-sc-015](#req-sc-015). A consumer uses the oracle to demonstrate +that scanning and materialization share the same authorized source set +through both install and re-integration; each lifecycle result matches +the oracle's authorized paths. + +### 12.5 Round-trip conformance (normative) + + +**[req-cf-001]** A conforming **Consumer** implementation MUST +satisfy an **idempotent round-trip** on any conforming manifest +and lockfile: re-parsing and re-serialising either artifact MUST +produce a byte-equivalent file (modulo trailing newline and YAML +flow-style cosmetics that the implementation is permitted to +canonicalise per [Section 4.3.4](#434-canonical-normalisation-writer-requirements) and +[Section 5.2](#52-per-entry-fields)). Unknown top-level keys, `x-*` +extension entries (per [req-ext-001](#req-ext-001), +[req-lk-014](#req-lk-014)), and fields the implementation does +not understand MUST be preserved verbatim across round-trip. + +### 12.6 Machine-readable conformance manifest (reserved) + +A machine-readable manifest enumerating every normative +requirement, its keyword (MUST/SHOULD/MAY), its class, and its +associated fixture path as a normative wire contract is **reserved for a +future revision**. Such a contract would +permit a conformance-suite runner to enumerate requirements +without parsing the prose, and will permit cross-implementation +result aggregation. Implementations satisfy the +"enumerable requirements" property via the prose anchors and the +[Appendix C](#appendix-c-index-of-normative-statements) index +table. + +This revision's **informative** companion manifest is +[`docs/public/specs/manifests/openapm-v0.2.requirements.yml`](/apm/specs/manifests/openapm-v0.2.requirements.yml) +with the shape sketched above (id, keyword, section, +conformance_class, plus optional fixture/oracle paths and +round-trip carve-outs). The companion is informative and exists +as a derived inventory; it is also the trip wire the +spec-conformance CI job can use, when this draft is explicitly selected +for assessment, to detect silent drift between the +canonical spec anchors, the Appendix C reader-aid table, and the +test marker coverage. The inactive foundation does not switch that +selection from v0.1; the combined successor owns complete executable +bindings and assessment. Implementations MAY consume it in this revision but +MUST NOT depend on its presence for normative conformance until +a future revision lifts the reservation. Its `spec_version` is exactly +`v0.2.0`; `requirements_format_version: "1"` and the unchanged +`requirements-v0.1.schema.json` identify the informative format, not +the assessed specification revision. + +--- + +## Citing this specification + +External documents, tooling, and conformance statements MUST cite +this specification using a stable URL. The following shortlinks are +prepared under the docs site; draft availability does not imply ratification: + +| URL | Resolves to | Use when | +|------------------------------------------------------|------------------------------|----------| +| `https://microsoft.github.io/apm/spec/v0.2.0` | This exact corrective revision | Toolchain or test fixture pin after publication. Its route identifies only v0.2.0, never a later patch. | +| `https://microsoft.github.io/apm/spec/v0.2` | This minor's initial corrective revision | Minor-line entry point; use the exact revision for evidence. | +| `https://microsoft.github.io/apm/spec/v0.1` | Preserved previous minor | Existing citations retain their previous interpretation and availability. | +| `https://microsoft.github.io/apm/spec/latest` | Newest ratified version | Human citation in prose. Toolchains MUST NOT pin to `latest`; pin to a versioned URL. | +| `https://microsoft.github.io/apm/spec` | Alias of `latest` | Short prose citation. Same restriction as `latest` -- do not pin tooling. | + +Each schema's embedded `$id` identifies it. Toolchains MUST pin to that +URL verbatim; schema files are byte-immortal there for this version's +lifetime. The published [`manifest-v0.1.schema.json`](/apm/specs/schemas/manifest-v0.1.schema.json) and +[`lockfile-v0.1.schema.json`](/apm/specs/schemas/lockfile-v0.1.schema.json) URLs and bytes remain unchanged and available; +[Appendix A](#appendix-a-normative-json-schemas-inline) selects the distinct +alias-aware resources inherited by this draft. + +The exact v0.2.0 content route is `/specs/openapm-v020/`. +Any future patch needs a distinct artifact and exact route; it MUST NOT +replace this artifact at that route. The existing `latest` and `/spec` +aliases are unchanged during preparation and may advance only at actual +ratification. + +## Appendix A. Normative JSON Schemas (inline) + +The schemas below define this draft's normative structural contract. +They inherit the prospective dependency identified in [Status](#status-of-this-document): +independent alias-aware manifest and lockfile resources, with distinct `$id` +URLs. The local-source/audit correction adds no wire-schema changes; +schema revisions are independent of specification versions. + +The prerequisite's human approval and public-comment period under +[Section 9.3](#93-amendment-process) remain pending. Listing these URLs +establishes neither approval nor publication. + +The selected wire-schema `$id` values are: + +- `https://microsoft.github.io/apm/specs/schemas/manifest-v0.1.41.schema.json` +- `https://microsoft.github.io/apm/specs/schemas/lockfile-v0.1.41.schema.json` +- `https://microsoft.github.io/apm/specs/schemas/policy-v0.1.schema.json` + +The informative requirements format separately reuses +`https://microsoft.github.io/apm/specs/schemas/requirements-v0.1.schema.json`. + +| Schema | Authoritative source (in-tree) | +|-----------------------|------------------------------------------------------------------------------------------------------| +| Manifest (`apm.yml`) | [`schemas/manifest-v0.1.41.schema.json`](/apm/specs/schemas/manifest-v0.1.41.schema.json) (JSON Schema 2020-12). | +| Lockfile (`apm.lock.yaml`) | [`schemas/lockfile-v0.1.41.schema.json`](/apm/specs/schemas/lockfile-v0.1.41.schema.json) (JSON Schema 2020-12). | +| Policy (`apm-policy.yml`) | [`schemas/policy-v0.1.schema.json`](/apm/specs/schemas/policy-v0.1.schema.json) (JSON Schema 2020-12). | +| Claude-Code marketplace (informational, emitted output) | `tests/fixtures/schemas/claude-code-marketplace.schema.json` | +| Claude-Code plugin (informational, emitted output) | `tests/fixtures/schemas/claude-code-plugin.schema.json` | + +Consumers checking the structural constraints for [req-mf-025](#req-mf-025) +choose the distinct 0.1.41 `$id` URLs above. Validation against the older +structural schemas does not waive newer prose or runtime requirements. + +`src/apm_cli/policy/schema.py` is a **non-normative cross-reference**; +the policy JSON Schema is authoritative. Manifest and lockfile validation +are JSON-Schema-only in this revision; a reference Python validator MAY be +added in a future minor revision without normative effect. + +Where a JSON Schema and the prose of this specification disagree, +the **prose** is authoritative and the schema is treated as an +errata candidate. + +**Retained schema limitations (informative).** The manifest schema has a +known source-key discriminator limitation: it rejects `git` entries with a +`path` modifier and `id` entries with an explicit `registry` modifier. +Those forms remain governed by [Section 4.3.2](#432-object-form). Separately, +the schema types `policy.hash` as a string without validating its digest +envelope; the applicable requirements in [req-mf-018](#req-mf-018) and +[req-lk-016](#req-lk-016) remain in force. Schema validation +alone is not a complete acceptance or conformance oracle for these cases. +This note does not waive semantic validation or change any preserved schema +file or identity; schema repairs require separately versioned artifacts. + +--- + +## Appendix B. Registry HTTP API (reserved) + +The registry HTTP API is **non-normative** in this revision. + +The companion page +[`registry-http-api.md`](../../reference/registry-http-api/) is +informational. A future revision may define the wire +contract normatively once independent server implementations exist. +Until then, conforming Consumers MAY implement the wire contract +described in the companion page but MUST NOT claim normative +Registry wire conformance against this revision, with the single exception of +the trust-anchor expectation in [req-rg-001](#req-rg-001). + +Reserved for a future revision (sketch only, non-normative here; +uppercase keywords within sketches do not activate obligations): + +- Archive container binding (`application/gzip` over `tar`; reject + `application/zip`; see also [req-sc-004](#req-sc-004)). +- Publisher attestation envelope (in-toto / SLSA; binds version to + publisher identity; see + [Section 10.12](#1012-publisher-provenance-and-attestations-reserved)). +- Yank / withdrawal semantics (see + [Section 7.9](#79-version-withdrawal-reserved)). + +The broader wire-contract slot remains reserved without renumbering +the existing conformance classes or disabling [req-rg-001](#req-rg-001). + +--- + +## Appendix C. Index of normative statements + +| ID | Keyword | Section | Class | +|------------------------------------------|---------|---------|-------------| +| [req-mf-001](#req-mf-001) | MUST | 4.1 | producer | +| [req-mf-002](#req-mf-002) | MUST | 4.1 | producer | +| [req-mf-003](#req-mf-003) | MUST | 4.1 | producer | +| [req-mf-004](#req-mf-004) | SHOULD | 4.1 | producer | +| [req-mf-005](#req-mf-005) | MUST | 4.2.1 | producer | +| [req-mf-006](#req-mf-006) | MUST | 4.1 | consumer | +| [req-mf-007](#req-mf-007) | MUST | 4.3.1 | consumer | +| [req-mf-008](#req-mf-008) | MUST | 4.3.3 | consumer | +| [req-mf-009](#req-mf-009) | MUST | 4.3.4 | consumer | +| [req-mf-010](#req-mf-010) | MUST | 4.3.2 | consumer | +| [req-mf-011](#req-mf-011) | MUST | 4.3.2 | consumer | +| [req-mf-012](#req-mf-012) | MUST | 4.3.6 | consumer | +| [req-mf-013](#req-mf-013) | MUST | 4.5 | consumer | +| [req-mf-014](#req-mf-014) | MUST | 4.2.3 | producer | +| [req-mf-015](#req-mf-015) | MUST | 4.2.3 | producer | +| [req-mf-016](#req-mf-016) | MUST | 4.3.5 | consumer | +| [req-mf-017](#req-mf-017) | MUST | 4.7 | producer | +| [req-mf-018](#req-mf-018) | MUST | 4.6.1 | consumer | +| [req-mf-019](#req-mf-019) | MUST | 4.2.4 | consumer | +| [req-mf-020](#req-mf-020) | MUST | 4.1 | consumer | +| [req-mf-021](#req-mf-021) | MUST | 4.8 | producer | +| [req-mf-022](#req-mf-022) | MUST | 4.3.2 | consumer | +| [req-mf-023](#req-mf-023) | MUST | 4.5 | consumer | +| [req-mf-024](#req-mf-024) | MUST | 4.3.2 | consumer | +| [req-mf-025](#req-mf-025) | MUST | 4.3.2 | consumer | +| [req-ext-001](#req-ext-001) | MUST | 4.1 | consumer | +| [req-ext-002](#req-ext-002) | MUST | 4.1 | producer | +| [req-lk-001](#req-lk-001) | MUST | 5.1 | consumer | +| [req-lk-002](#req-lk-002) | MUST | 5.4 | consumer | +| [req-lk-003](#req-lk-003) | MUST | 5.2 | consumer | +| [req-lk-004](#req-lk-004) | MUST | 5.4 | consumer | +| [req-lk-005](#req-lk-005) | MUST | 5.5 | consumer | +| [req-lk-006](#req-lk-006) | MUST | 5.5 | consumer | +| [req-lk-007](#req-lk-007) | SHOULD | 5.5 | consumer | +| [req-lk-008](#req-lk-008) | MUST | 5.6 | consumer | +| [req-lk-009](#req-lk-009) | MUST | 5.6 | consumer | +| [req-lk-010](#req-lk-010) | MUST | 5.6 | consumer | +| [req-lk-011](#req-lk-011) | MUST | 5.2 | consumer | +| [req-lk-012](#req-lk-012) | MUST | 5.2 | consumer | +| [req-lk-013](#req-lk-013) | MUST | 5.2 | consumer | +| [req-lk-014](#req-lk-014) | MUST | 5.2 | consumer | +| [req-lk-015](#req-lk-015) | MUST | 5.6.4 | consumer | +| [req-lk-016](#req-lk-016) | MUST | 5.2 | consumer | +| [req-lk-017](#req-lk-017) | MUST | 5.2 | consumer | +| [req-lk-018](#req-lk-018) | SHOULD | 5.5 | consumer | +| [req-lk-019](#req-lk-019) | MUST | 5.2 | consumer | +| [req-lk-020](#req-lk-020) | MUST | 5.2 | consumer | +| [req-lk-021](#req-lk-021) | MUST | 5.2 | consumer | +| [req-lk-022](#req-lk-022) | MUST | 5.2 | consumer | +| [req-lk-023](#req-lk-023) | MUST | 5.5 | consumer | +| [req-pl-001](#req-pl-001) | MUST | 6.1 | governance | +| [req-pl-002](#req-pl-002) | MUST | 6.2 | governance | +| [req-pl-003](#req-pl-003) | MUST | 6.4 | governance | +| [req-pl-004](#req-pl-004) | MUST | 6.4 | governance | +| [req-pl-005](#req-pl-005) | MUST | 6.5 | governance | +| [req-pl-006](#req-pl-006) | MUST | 6.4 | governance | +| [req-pl-007](#req-pl-007) | MUST | 6.3.1 | governance | +| [req-pl-008](#req-pl-008) | MUST | 6.3.1 | governance | +| [req-pl-009](#req-pl-009) | MUST | 6.6 | governance | +| [req-pl-010](#req-pl-010) | MUST | 6.2 | governance | +| [req-pl-011](#req-pl-011) | MUST | 6.1.1 | governance | +| [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-pl-016](#req-pl-016) | MUST | 6.8 | governance | +| [req-pl-017](#req-pl-017) | MUST | 6.8 | governance | +| [req-pl-018](#req-pl-018) | MUST | 6.3.1 | 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 | +| [req-rs-004](#req-rs-004) | MUST | 7.5 | consumer | +| [req-rs-005](#req-rs-005) | MUST | 7.6 | consumer | +| [req-rs-006](#req-rs-006) | MUST | 7.2 | consumer | +| [req-rs-007](#req-rs-007) | MUST | 7.3 | consumer | +| [req-rs-008](#req-rs-008) | MUST | 7.1 | consumer | +| [req-rs-009](#req-rs-009) | MUST | 7.5.1 | consumer | +| [req-rs-010](#req-rs-010) | MUST | 7.2 | consumer | +| [req-rs-011](#req-rs-011) | MUST | 7.7 | consumer | +| [req-rs-012](#req-rs-012) | MUST | 7.7 | consumer | +| [req-rs-013](#req-rs-013) | MUST | 7.2 | consumer | +| [req-rs-014](#req-rs-014) | MUST | 7.3.1 | consumer | +| [req-rs-015](#req-rs-015) | MUST | 7.5 | consumer | +| [req-rs-016](#req-rs-016) | MUST | 7.2 | consumer | +| [req-rs-017](#req-rs-017) | MUST | 7.7 | consumer | +| [req-pr-001](#req-pr-001) | MUST | 8.2 | consumer | +| [req-pr-002](#req-pr-002) | MUST | 8.3 | consumer | +| [req-pr-003](#req-pr-003) | MUST | 8.3 | consumer | +| [req-pr-004](#req-pr-004) | MUST | 7.8 | producer | +| [req-pr-005](#req-pr-005) | SHOULD | 7.8 | producer | +| [req-pr-006](#req-pr-006) | MUST | 8.1 | consumer | +| [req-pr-007](#req-pr-007) | MUST | 8.1 | consumer | +| [req-tg-001](#req-tg-001) | MUST | 8.4 | consumer | +| [req-tg-002](#req-tg-002) | MUST | 8.5 | consumer | +| [req-tg-003](#req-tg-003) | MUST | 8.5 | consumer | +| [req-tg-004](#req-tg-004) | MUST | 4.2.1 | consumer | +| [req-tg-005](#req-tg-005) | MUST | 8.5 | consumer | +| [req-tg-006](#req-tg-006) | MUST | 8.5 | consumer | +| [req-tg-007](#req-tg-007) | MUST | 8.5 | consumer | +| [req-tg-008](#req-tg-008) | MUST | 8.5.3 | consumer | +| [req-tg-009](#req-tg-009) | MUST | 8.5.1 | consumer | +| [req-tg-010](#req-tg-010) | MUST | 8.5.4 | consumer | +| [req-tg-011](#req-tg-011) | MUST | 8.5.5 | consumer | +| [req-tg-012](#req-tg-012) | MUST | 8.5.6 | consumer | +| [req-tg-013](#req-tg-013) | MUST | 8.5.7 | consumer | +| [req-tg-014](#req-tg-014) | MUST | 8.5.8 | consumer | +| [req-sc-001](#req-sc-001) | MUST | 10.4 | consumer | +| [req-sc-002](#req-sc-002) | MUST | 10.9 | consumer | +| [req-sc-003](#req-sc-003) | MUST | 10.3 | consumer | +| [req-sc-004](#req-sc-004) | MUST | 10.5 | consumer | +| [req-sc-005](#req-sc-005) | MUST | 10.3 | consumer | +| [req-sc-006](#req-sc-006) | MUST | 4.2.3 | consumer | +| [req-sc-007](#req-sc-007) | MUST | 10.3 | consumer | +| [req-sc-008](#req-sc-008) | SHOULD | 10.3 | consumer | +| [req-sc-009](#req-sc-009) | MUST | 10.13 | consumer | +| [req-sc-010](#req-sc-010) | MUST | 10.13 | consumer | +| [req-sc-011](#req-sc-011) | MUST | 10.14 | consumer | +| [req-sc-012](#req-sc-012) | MUST | 10.14 | consumer | +| [req-sc-013](#req-sc-013) | MUST | 10.3 | consumer | +| [req-sc-014](#req-sc-014) | MUST | 10.15 | consumer | +| [req-sc-015](#req-sc-015) | MUST | 10.16 | consumer | +| [req-rg-001](#req-rg-001) | MUST | 11.3.3 | registry | +| [req-cf-001](#req-cf-001) | MUST | 12.5 | consumer | +| [req-cf-002](#req-cf-002) | MUST | 12.3 | consumer | + +**Total normative statements: 124** (119 MUST, 5 SHOULD). + +The [req-mf-016](#req-mf-016) consumer entry covers source anchoring, +user-scope admission, remote-repository containment, and local +internal-symlink handling. Its identifier, class, and MUST keyword +are unchanged by the local-path correction; clauses (a)-(d) remain +one indexed requirement. + +--- + +## Appendix D. Revision history + +### v0.2.0 corrective foundation (draft, inactive; not published) + +This distinct minor reconciles the local-source correction in +[microsoft/apm#2818](https://github.com/microsoft/apm/issues/2818) +and the audit current-intent/read-only correction in +[microsoft/apm#2816](https://github.com/microsoft/apm/issues/2816). +Beyond the dependency below, only [req-mf-016](#req-mf-016)'s local-source +contract and Section 5.5's audit contract change behavior; Section 8.4 and +[req-pl-014](#req-pl-014) retain their corresponding selection and exit-scope +clarifications. + +**Prospective dependency.** This draft inherits +[microsoft/apm#2901](https://github.com/microsoft/apm/pull/2901) at exact commit +[`5a0d872b9dd63b78b08923eddfad0ed1f9b369b4`](https://github.com/microsoft/apm/commit/5a0d872b9dd63b78b08923eddfad0ed1f9b369b4), +not a published or ratified baseline. Its 0.1.41 candidate adds +[req-mf-025](#req-mf-025) and the optional lock-entry `alias` field. +Under Section 9.2 this is additive optional support and a defensive definition +of previously unspecified behavior, not behavior-neutral errata: unsafe or +reserved aliases can newly fail; valid dotted aliases remain accepted; +whitespace is canonicalized; recorded aliases determine replay placement; +absent aliases retain the unaliased layout. Source identity and source-path +permissions do not change. Unknown-field preservation is not placement support. +The requirement and its disclosure, cleanup, and threat mappings are inherited, +not introduced by the local-source/audit correction. Its distinct schema +resources and pending amendment status are recorded in +[Appendix A](#appendix-a-normative-json-schemas-inline). + +Statement count from that prospective dependency: **123 -> 124** +(119 MUST, 5 SHOULD); only [req-lk-023](#req-lk-023) is added to its inventory. +The complete 0.1.40 amendment, including [req-pl-018](#req-pl-018), its identity, +policy, disclosure, and security text, and Section 9.2's deterministic-evaluation +allowance, remains inherited. No other identifier is added, removed, or renumbered. + +**Classification and preservation.** The local-source and audit corrections +are substantive conformance changes under Sections 9.1, 9.2, and 9.4, not same-minor +errata. The retained v0.1 artifact and requirements manifest match that exact +prospective dependency byte-for-byte. Appendix A selects its two new schema +resources; the four original v0.1 schemas retain their URLs and exact bytes +from `e38261c5db4d893d6ddebc3925742e4e3bd2ba74`. The import establishes no +publication or ratification. v0.1 remains active, available, and supported +indefinitely, with no removal date. +Section 9.5 constrains announcement-to-removal, not parallel availability +of a new minor; no migration exception is claimed. + +An admitted local sibling outside the project root can satisfy this +revision while violating the old blanket rule. Neither this correction +nor any future test bindings establishes that the reference CLI ever +conformed to the previous minor's req-mf-016. + +**Authorization and pending record.** The maintainer authorized immediate +drafting of this bounded normative reconciliation and waived only the +14-day public-comment period in Section 9.3 step 4 in the +[one-amendment public decision](https://github.com/microsoft/apm/issues/2818#issuecomment-5558647529). +This is not a conformance waiver. At least two qualified non-author human +reviewers remain required under Section 9.3 step 3: one with implementation +experience and one with consumer/integrator experience. Implementation +evidence and ratification remain pending; AI review supplies no human approval. +The publication owner supplies any actual record later. Section 9 remains +unchanged from the prospective dependency. + +The [Status](#status-of-this-document) assigns foundation-only ownership to +#2820, coupled executable assessment to #2919, and aggregation to #2923. +This record activates no assessment selector, runtime behavior, publication, +or latest alias and records no announcement, publication, or ratification date. + +No previously reserved workspace, nesting, attestation, HTTP wire, +internationalization, range-widening, withdrawal, or default-frozen feature +is activated. Independently versioned companions remain unchanged. + +### Previous-minor history (informative) + +The following entries are retained as history, not renewed feature promises +or evidence of approval for this revision. The operative reservation text +above controls this revision. + +| Version | Date | Changes | +|---------|------------|----------------------------------------------------------| +| 0.1 | 2026-05-10 | Initial editor's Working Draft. | +| 0.1-r2 | 2026-05-17 | Round-2 adversarial revision. Closed mandatory FOLDs F1-F10: pinned semver dialect (node-semver + semver 2.0.0); tri-modal transitive conflict resolution; vendor-host neutrality (default_host, pluggable policy discovery, host class via PSL+aliases); hash envelope on every stored hash; canonical git tree-hash definition; mirror-tolerant fetch; producer release contract (tag-version alignment, SHOULD-sign); update operation semantics; lockfile_version monotonicity; reserved-slot prose for 11 deferrals (workspaces, x-* extensions, machine-readable conformance, update --aggressive, frozen-default flip, target registry companion, version yank, attestations, registry HTTP, mirror-tolerance, .agents/ partition); inline JSON Schemas in Appendix A; YAML safe subset; archive container binding; credential redaction. Conformance-statement count: 56 -> 83. Companion seed fixture tree shipped under `tests/fixtures/spec-conformance/`. | +| 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. | +| 0.1.5 | 2026-06-20 | Spec-citation fold for the executable primitive approval gate. Added new Section 10.13 "Executable primitive approval gate" with two consumer MUSTs: [req-sc-009] (deny deployment of any hook, bin, MCP server, or canvas extension from a dependency not listed in the effective `allowExecutables` approval set when the block is present -- fail closed) and [req-sc-010] (persist interactive approval decisions user-locally, not in the project `apm.yml`, so one developer's approval cannot propagate via VCS to teammates). Added rows 11 and 12 to the Section 10.11 summary table. Section 11.3.2 Consumer enumeration and Appendix C updated. Statement count: 90 -> 92 (87 MUST, 5 SHOULD). | +| 0.1.6 | 2026-06-25 | Spec-citation fold for executable trust precedence and audit fidelity. Added Section 10.14 with two consumer MUSTs: [req-sc-011] (executable trust resolves through one deny-wins precedence; an org executables.deny/deny_all overrides any project or user grant; the install gate and the audit MUST reach the identical outcome via the shared resolver) and [req-sc-012] (a required package's audit asserts lockfile presence, not executable deployment; a present-but-withheld required package satisfies the presence requirement and surfaces a distinct withheld-executable signal). Added rows 13 and 14 to the Section 10.11 summary table. Section 11.3.2 and Appendix C updated. Statement count 92 -> 94 (89 MUST, 5 SHOULD). | +| 0.1.7 | 2026-06-27 | Spec-citation fold for lockfile inventory metadata (closes the #1888 Mode-B silent-extension gate). Added [req-lk-019] (Section 5.2, consumer MUST): the optional per-entry `name` and `version` fields are self-asserted inventory metadata only -- preserved on round-trip per [req-lk-011], never a trust anchor, and never an identity, deduplication, or frozen-replay key (identity/replay derive solely from `repo_url`, `resolved_commit`, `resolved_tag`/`constraint`, and the recorded hash envelopes); their presence is additive and MUST NOT change `lockfile_version`. Added the `name` row to the Section 5.2 per-entry field table and broadened the `version` row note to non-semver sources; added `name` to the `entry` `$defs` in `lockfile-v0.1.schema.json` (sibling of `declared_license`). Section 11.3.2 Consumer enumeration and Appendix C updated. Statement count: 94 -> 95 (90 MUST, 5 SHOULD). | +| 0.1.8 | 2026-06-29 | Normative amendment (semver-zero `0.x` minor) to [req-lk-012]: redefined the `deployed_file_hashes` / `local_deployed_file_hashes` domain from "bytes as written to disk" to the *canonical content* -- UTF-8 text (decodable, no NUL byte) is hashed over its `\r\n` -> `\n` normalized form (a lone `\r` is preserved); binary is hashed raw. This makes the per-deployed-file hash platform-invariant so `apm audit --ci` no longer reports a false `content-integrity` drift when a file is checked out with `\r\n` on Windows (`core.autocrlf=true`) and `\n` on POSIX (apm#1952); it harmonizes `content-integrity` with the drift-replay normalizer. Preserving a bare `\r` keeps the carriage-return smuggling vector hash-visible. [req-lk-017] reworded to re-verify against the [req-lk-012] canonical domain rather than raw on-disk bytes (consistency, not a new obligation). Migration: lockfiles whose hashes were recorded on Windows before this amendment carry `\r\n`-domain hashes; one `apm install` re-records them in the canonical domain. No statement-count change (existing MUST modified, none added); 95 (90 MUST, 5 SHOULD). Subject to the Section 9.3 amendment panel + comment window. | +| 0.1.9 | 2026-07-04 | Spec-citation fold for network-free lockfile replay (closes the srobroek Mode-B silent-extension gate on the lockfile-seeded resolver cache). Added [req-rs-015] (Section 7.5, consumer MUST): a non-update install (not `apm update` and not `--refresh`/re-resolution) MUST replay a lockfile entry that records a `resolved_commit` by reusing that recorded commit as the resolution result without issuing any network ref-resolution -- no commits-API query, no `git ls-remote`, no clone -- for that entry, PROVIDED drift detection against the manifest reference does not require re-resolution; on drift or under an explicit `apm update`/`--refresh` ([req-rs-011], [req-rs-012]) the consumer MUST re-resolve over the network as usual. The recorded `resolved_commit` is the lockfile's resolution anchor ([req-lk-003]); replaying it is scoped to entries recording a `resolved_commit` WITHOUT a `resolved_tag` (git-literal and untagged-branch entries per [req-rs-003]), leaves content integrity subject to `tree_sha256` ([req-lk-015]) and `resolved_hash` ([req-lk-013]), and defines drift locally as the manifest `ref` no longer being character-equal to the lockfile `resolved_ref`; this makes a warm install of an already-locked reference network-free at the resolution step, extending the reproducible-and-offline resolution guarantee to commit-pinned and branch-tracking entries not covered by the semver-range equivalence of [req-rs-004]. Section 7.11 and Section 11.3.2 Consumer enumerations and Appendix C updated. Statement count: 95 -> 96 (91 MUST, 5 SHOULD). Subject to the Section 9.3 amendment panel + comment window. | +| 0.1.10 | 2026-07-04 | Spec-citation fold for Antigravity native instruction rules (closes the #1984 Mode-B silent-extension gate). Added [req-tg-005] (Section 8.5, consumer MUST): Antigravity instruction rules are deployed under `.agents/rules/.md`, `applyTo` is rendered as `trigger: glob` plus `globs` (scalar or sequence), and compile-time deduplication only treats expected Antigravity rule filenames as deployed rules so unrelated `.md` files cannot suppress `AGENTS.md` content. Added `antigravity` to the Section 4.2.1 canonical target set and clarified that `all` excludes explicit-only targets. Statement count: 96 -> 97 (92 MUST, 5 SHOULD). | +| 0.1.11 | 2026-07-09 | Spec-guardian editorial+defensive fold on the Antigravity instruction-rule contract (no new normative statements; statement count remains 97 (92 MUST, 5 SHOULD)). Section 4.2.1: defined the **auto-detectable** vs **explicit-only** target taxonomy deterministically (a target is auto-detectable when the OpenAPM Target Registry publishes at least one detection predicate) and rewrote the `all` expansion to key off it, naming `agent-skills` and `antigravity` as the v0.1 explicit-only set (with a Section 8.4 cross-reference). [req-tg-001] extended: a target registered without a detection predicate MUST NOT be auto-detected and MUST be excluded from `all`, generalising the prior `agent-skills`-only clause to cover `antigravity`. [req-tg-005] extended: pinned a canonical `globs` representation (YAML scalar for exactly one glob, YAML block sequence for two or more, no frontmatter block when `applyTo` is absent) so deployed-file content hashes are reproducible across implementations; redefined the deduplication scope from "expected Antigravity rule filenames" to filenames derived from the currently-resolved instruction primitives recorded in `apm.lock.yaml` and the manifest, closing a fail-open interpretation where an unrelated `.agents/rules/*.md` file could suppress `AGENTS.md` content; lowercased the `antigravity` identifier and added an editorial note scoping the normative citation of the concrete deploy path. [req-tg-002] subdirectory-partition list updated to include `.agents/rules/`. No normative count change. | +| 0.1.12 | 2026-07-10 | Spec-citation fold for inactive-target lockfile reconciliation. Added [req-lk-020] (Section 5.2, consumer MUST): a non-frozen rewrite with a declared target set preserves paths attributable to current, another declared, or implementation-recognized targets that activate outside the manifest; removes prior paths attributable to none of them; applies the same decision to per-entry and top-level deployed-file lists and hash maps; and preserves prior paths when no target set is declared or attribution is indeterminate. Statement count: 97 -> 98 (93 MUST, 5 SHOULD). | +| 0.1.13 | 2026-07-14 | Defensive clarification of existing lockfile requirements (no new normative statements; statement count remains 98 (93 MUST, 5 SHOULD)). [req-lk-003] now requires a conformance audit to reject disagreement between a full-SHA manifest pin and `resolved_commit`. [req-lk-020] now preserves paths freshly deployed by an active dependency when orphan cleanup encounters the same path under a prior dependency identity. | +| 0.1.14 | 2026-07-15 | Spec-citation fold for complete repository identity through resolution and materialization (closes #2191). Added [req-rs-016] (Section 7.2, consumer MUST): repository identity includes normalized host, explicit port, and the complete credential-free repository path; distinct identities MUST NOT share cached source material merely because they use the same ref or a common path prefix; identical identity and ref MAY reuse cached source material. Section 7.11 and Section 11.3.2 Consumer enumerations and Appendix C updated. Statement count: 98 -> 99 (94 MUST, 5 SHOULD). | +| 0.1.15 | 2026-07-15 | Spec-citation fold for lossy agent target conversion (closes the #2181 Mode-B silent-extension gate). Added [req-tg-006] (Section 8.5, consumer MUST): target-native agent conversion either preserves source-declared capability restrictions exactly or emits a default-visible, actionable diagnostic naming the source agent, each discarded field, and the broader-access risk before the overall operation returns; malformed or non-mapping frontmatter receives an unverifiable-restriction diagnostic. The requirement does not define a target-native restriction encoding or mandate a nonzero exit status. Statement count: 99 -> 100 (95 MUST, 5 SHOULD). | +| 0.1.16 | 2026-07-17 | Spec-citation fold for dropped-target merge-hook reconciliation (closes the #2253 Mode-B silent-extension gate). Added [req-lk-021] (Section 5.2, consumer MUST): extends [req-lk-020]'s target-reconciliation preserve/remove decision to merge-based hook configuration and its ownership record, since that state is deliberately outside `deployed_files`/`local_deployed_files` tracking and so was never reachable by req-lk-020's literal text -- narrowing a project's declared target set now also reconciles the dropped target's consumer-owned merge-hook entries, while preserving entries not carrying consumer ownership and preserving state for targets still attributable per req-lk-020's own (a)-(c) test. Section 11.3.2 Consumer enumeration and Appendix C updated. Statement count: 100 -> 101 (96 MUST, 5 SHOULD). | +| 0.1.17 | 2026-07-17 | Spec-citation fold for deployment-ledger owner integrity (closes the PR #2292 Mode-B silent-extension gate on the policy engine and audit exit contract). Added [req-pl-016] (Section 6.8, governance MUST): a canonical deployment-ledger owner that does not resolve to a dependency entry in `apm.lock.yaml` is a hard integrity failure, independent of `security.audit.fail_on_drift`; an audit MUST exit non-zero in BOTH default and CI modes when such a stale ownership record is present, MUST NOT mutate deployed bytes (for example under strip) while ownership is invalid, and MUST name each affected locator with its invalid owner(s) plus one reconcile-ownership remediation. Explicitly distinguished from ordinary deployed-file drift, which stays advisory in default mode per [req-pl-014]; a durable ownership record is not a file edit, so its staleness surfaces unconditionally. Reconciled the Section 6.9 and Section 11.3.4 governance enumerations (the latter also gained the previously-missing [req-pl-015] row). Section 1.3 and Appendix C count sites updated. Statement count: 101 -> 102 (97 MUST, 5 SHOULD). | +| 0.1.18 | 2026-07-17 | Spec-citation fold for project-scope post-install compilation guidance (closes #2057). Added [req-tg-007] (Section 8.5, consumer MUST): after a non-dry-run project install adds a package, a consumer that finds dependency instruction primitives for an active root-context compilation target emits a default-visible diagnostic naming the follow-up compile operation and root context output class. The diagnostic is suppressed for dry runs, no-op installs, trees without dependency instructions, and target sets that deploy instructions as native per-file rules. Section 8.7 and Section 11.3.2 Consumer enumerations and Appendix C updated. Statement count: 102 -> 103 (98 MUST, 5 SHOULD). | +| 0.1.19 | 2026-07-18 | Spec-citation fold for stale persisted skill subsets (closes #2116). Added [req-mf-022] (Section 4.3.2, consumer MUST): when a non-empty manifest `skills:` subset matches no available skill in a dependency that exposes selectable skills, the consumer emits a default-visible diagnostic naming the dependency plus the requested and available skill names before install returns; the diagnostic does not by itself require a nonzero install status. Section 11.3.2 Consumer enumeration and Appendix C updated. Statement count: 103 -> 104 (99 MUST, 5 SHOULD). | +| 0.1.20 | 2026-07-30 | Defensive amendment of [req-lk-006] (no new normative statement; count remains 104 (99 MUST, 5 SHOULD)): frozen validation now covers direct MCP server names and configurations as well as package pins, runs before lockfile, target-config, deployment, or cache mutation, and rejects manifest dependency mutation. | +| 0.1.21 | 2026-07-31 | Spec-citation fold for package-declared target restrictions (closes #2321 Mode-B silent-extension gate). Added [req-tg-008] (Section 8.5.3, consumer MUST): a consumer MUST treat a package's declared `target:`/`targets:` field as a restriction-only filter on all target-scoped primitive integration; if the field resolves to a non-empty set that does not contain `all`, the consumer MUST NOT deliver that package's primitives to any active integration target not in the declared set; the filter composes by intersection with the consumer-side per-dependency `targets:` filter and can only narrow, never expand. Section 8.7, Section 11.3.2 Consumer enumeration, and Appendix C updated. Statement count: 104 -> 105 (100 MUST, 5 SHOULD). | +| 0.1.22 | 2026-07-31 | Spec-citation fold for deterministic configured-host credential isolation (closes #2338). Added [req-sc-013] (Section 10.3, consumer MUST): a consumer selects one effective host class before credential resolution, applies documented deterministic precedence when configuration signals overlap, exposes only credentials belonging to the selected class to requests and child processes, and preserves an explicit non-default port in both transport and credential scope. Clarified [req-sc-005] so this configured override is not prohibited by its default host-class collapse rule. Section 1.3, Section 10.11, Section 11.3.2 Consumer enumeration, and Appendix C updated. Statement count: 105 -> 106 (101 MUST, 5 SHOULD). | +| 0.1.23 | 2026-07-31 | Spec-citation fold for case-preserving dependency materialization (closes #2347). Added [req-lk-022] (Section 5.2, consumer MUST): a consumer that case-folds repository identity but retains different source spelling records `materialization_repo_url`, validates it maps to the same canonical identity, excludes it from identity/cache/sort/trust decisions, preserves exact virtual-path casing, and either transactionally migrates one stale case variant or fails closed without deleting colliding paths. Defined rollback semantics for case-only rename and preserved interrupted recovery state. Added the field to the lockfile schema and conformance fixture, plus migration and collision conformance oracles. Hardened lockfile schema: `repo_url` now carries `minLength: 1` to match the prose requirement that git-sourced entries provide a non-empty canonical identifier ([req-lk-003](#req-lk-003)). Section 5.7, Section 10.11, Section 11.3.2, and Appendix C updated. Statement count: 106 -> 107 (102 MUST, 5 SHOULD). | +| 0.1.24 | 2026-08-03 | Spec-citation fold for fail-closed Kiro agent vocabulary gate (closes #2089 Mode-B silent-extension gate). Added [req-tg-009] (Section 8.5.1, consumer MUST): a consumer deploying an agent primitive into a target with a fixed, enumerable capability vocabulary MUST fail closed -- writing zero bytes and emitting an actionable diagnostic -- if any source-declared tool falls outside the approved set; the gate fires per agent independently and does not block vocabulary-conformant sibling agents; the gate applies only to targets included in the effective intersection under [req-tg-008]; content-identity fast-paths are not exempt. Added editorial note naming the Target Registry companion as the vocabulary authority and mandating version-pinning for conformance testing. Section 8.7, Section 11.3.2 Consumer enumeration, and Appendix C updated. Statement count: 107 -> 108 (103 MUST, 5 SHOULD). | +| 0.1.25 | 2026-08-03 | Spec-citation fold for portable project-scoped Claude hooks (closes #2408 Mode-B silent-extension gate). Added [req-tg-010] (Section 8.5.4, consumer MUST): a project-scoped native hook that may launch outside the consumer project anchors its generated command through the target portable project-directory environment variable, preserves the relative hook path, executes successfully when the variable identifies the consumer project, and never embeds an absolute checkout path; shell-expansion path syntax is rejected. Claude uses `CLAUDE_PROJECT_DIR` in POSIX and `$env:CLAUDE_PROJECT_DIR` in PowerShell. Section 8.7, Section 11.3.2 Consumer enumeration, and Appendix C updated. Statement count: 108 -> 109 (104 MUST, 5 SHOULD). | +| 0.1.26 | 2026-08-03 | Spec-citation fold for VS Code OCI/Docker MCP runtime argument resolution (closes #2438). Added [req-mf-023] (Section 4.5, consumer MUST): a non-secret runtime variable resolves every `{name}` occurrence across package runtime and package arguments, an unresolved template is never written literally, and package-scoped secret metadata uses VS Code secret-input references instead of generated config bytes. Section 4.9, Section 11.3.2, and Appendix C updated. Statement count: 109 -> 110 (105 MUST, 5 SHOULD). | +| 0.1.27 | 2026-08-03 | Spec-citation fold for object-form registry identity preservation on CLI-driven manifest updates (closes the PR #2166 Mode-B silent-extension gate). Added [req-mf-024] (Section 4.3.2, consumer MUST): a consumer MUST NOT silently rewrite an existing `id:`-form (registry-sourced) manifest entry into a `git:`-form entry when persisting a subsequent CLI-driven update (e.g. an additive `--skill` pin) for the same dependency identity; when a CLI-parsed reference is ambiguous about its source but an existing manifest entry for the same identity already resolves to the `registry` source, the existing entry's source MUST be honored, and an update that would otherwise replace a registry-sourced entry with a non-registry-shaped entry MUST be rejected with a diagnostic naming the identity. Section 4.9 and Section 11.3.2 Consumer enumerations and Appendix C updated. Statement count: 110 -> 111 (106 MUST, 5 SHOULD). | +| 0.1.28 | 2026-08-06 | Spec-citation fold for per-invocation executable consent in non-interactive contexts (closes #1620 Mode-B silent-extension gate). Added [req-sc-014] (Section 10.15, consumer MUST): a consumer that supports a per-invocation consent flag for bin/ executable deployment MUST deny deployment by default when stdout is not a TTY, unless the operator has explicitly opted in for that invocation; an explicit opt-in overrides the non-interactive default and permits deployment; an explicit opt-out overrides the default and denies deployment even in a terminal; the allowExecutables policy gate [req-sc-009] is evaluated first and always takes precedence. Added row 19 to the Section 10.11 summary table. Section 11.3.2 Consumer enumeration and Appendix C updated. Statement count: 111 -> 112 (107 MUST, 5 SHOULD). | +| 0.1.29 | 2026-08-22 | Spec-citation fold for the Agent Plugins v1 native-lifecycle deployment boundary (closes #2522 Mode-B silent-extension gate). Added [req-tg-011] (Section 8.5.5, consumer MUST): a consumer treats a schema-bearing Agent Plugins v1 dependency as opaque to legacy primitive projection and requires an applicable target-native lifecycle for deployment. The original boundary wording was refined by 0.1.35 to permit acquisition, materialization, and lock recording before a dependency-scoped deployment skip, while ordinary dependencies in the same install remain eligible for deployment and `--dry-run` reports the same decision without mutation. Section 8.7 and Appendix C updated. Statement count: 112 -> 113 (108 MUST, 5 SHOULD). | +| 0.1.30 | 2026-08-23 | Spec-citation fold for plugin-root hook command resolution (closes #2639 Mode-B silent-extension gate). Added [req-tg-012] (Section 8.5.6, consumer MUST): a consumer that resolves plugin-root placeholders treats a matching quoted placeholder followed by an outside path separator equivalently to the fully quoted path, preserves balanced expandable quoting, and emits a default-visible diagnostic instead of silently deploying any supported placeholder that remains unresolved. Section 8.7, Section 11.3.2, and Appendix C updated. Statement count: 113 -> 114 (109 MUST, 5 SHOULD). | +| 0.1.31 | 2026-08-23 | Spec-citation fold for Azure DevOps organization-policy discovery. Added [req-pl-017] (Section 6.8, governance MUST): discovery uses `apm/apm-policy` first and can use legacy `_apm/_apm` only after an HTTP 404; all non-404 failures stop without fallback, and a successful legacy fallback emits one actionable migration warning. Section 6.9, Section 11.3.4, and Appendix C updated. Statement count: 114 -> 115 (110 MUST, 5 SHOULD). | +| 0.1.32 | 2026-08-23 | Spec-citation fold for authoritative legacy plugin skill declarations (closes #2537). Added [req-pr-006] (Section 8.1, consumer MUST): omitted `skills` alone enables conventional discovery; a string or list replaces discovery; explicit empty, invalid, escaping, symlinked, and duplicate-derived entries contribute no skills; declared containers contribute only immediate child skills; and only resulting names are eligible for enumeration, selection, or deployment. Section 8.7, Section 11.3.2, Appendix C, and conformance coverage updated. Statement count: 115 -> 116 (111 MUST, 5 SHOULD). | +| 0.1.33 | 2026-08-23 | Spec-citation fold for authorized pre-deployment scan scope (closes #2490 Mode-B silent-extension gate). Added [req-sc-015] (Section 10.16, consumer MUST): a consumer derives one post-authorization source-file set for every install and uninstall re-integration materialization lifecycle; excludes symlink files and does not traverse symlinked directories; scans and materializes only that set; rejects a selected blocking finding before a source-derived target write; and does not scan or materialize source-only package files. Added row 20 to the Section 10.11 summary table. Reconciled with concurrent [req-pl-017] and [req-pr-006] and retained all amendments. Section 1.3, Section 11.3.2, and Appendix C updated. Statement count: 116 -> 117 (112 MUST, 5 SHOULD). | +| 0.1.34 | 2026-08-25 | Spec-citation fold for root-declared Plugin component staging containment (closes #2556). Added [req-pr-007] (Section 8.1, consumer MUST): a consumer canonicalizes the non-symlink component-source root and prunes the current operation's materialization subtree before traversal. Section 8.7, Section 11.3.2, Appendix C, and conformance coverage updated. Statement count: 117 -> 118 (113 MUST, 5 SHOULD). | +| 0.1.35 | 2026-08-27 | Stale-spec (Mode C) amendment recording a machine-verifiable native Agent Plugins lifecycle. Added [req-tg-013] (Section 8.5.7, consumer MUST): schema, effective-target, integrity, security, and executable admission drives one aggregate direct-plus-transitive registration per scope without locating, invoking, or version-checking a host binary during lifecycle operations; packages remain materialized in place and opaque to legacy projection; direct dependencies win plugin-name collisions over transitive dependencies, same-precedence collisions fail, and recorded ownership does not silently repoint to a transitive claimant; a consumer-owned marketplace identifier and activation suffix are reserved only with the exact generated directory-marketplace entry; the ownership record is primary evidence, while missing-record recovery may re-adopt only that exact entry and reconcile the reserved namespace; foreign collisions and invalid JSON fail closed; unrelated JSON values are preserved semantically though stable serialization may reformat them; and catalog, ownership-record, and settings writes form one rollback unit. Revised [req-tg-011] to clarify that acquisition, materialization, and lock recording may precede target exclusion, which creates no target registration or primitive projection and does not block ordinary dependencies in the same batch. Compatibility is qualified at release or build time by the pinned real-host lifecycle suite; runtime availability is the operator's responsibility. Added the native plugin namespace and ownership-recovery threat to Section 10. Section 8.7, Section 11.3.2 Consumer enumeration, Appendix C, and conformance coverage updated. Statement count: 118 -> 119 (114 MUST, 5 SHOULD). | +| 0.1.36 | 2026-08-29 | Editorial and defensive alignment for [req-tg-011] and [req-tg-013]. Named the [req-tg-008] result as the effective target intersection; scoped aggregate registration and plugin-name claimant selection to dependencies that passed admission; required target contraction to retire consumer-owned native registration; required advisory uninstall, prune, and restore reconciliation to omit ambiguous or changed-owner plugin entries without blocking cleanup; restored exact removal boundaries; defined directory-marketplace entries; and added reserved namespace disclosure to Section 11.2. Added conformance coverage for direct-owner promotion, advisory collision cleanup, and transitive owner-repoint refusal. Statement count remains 119 (114 MUST, 5 SHOULD). | +| 0.1.37 | 2026-09-01 | Spec-citation fold for safe full-SHA revision-pin updates (closes #2511 Mode-B silent-extension gate). Added [req-rs-017] (Section 7.7, consumer MUST): a consumer extension may replace a full commit pin only with the peeled commit of the highest eligible non-prerelease annotated tag, including 0.x; no eligible tag retains the current commit and allows unrelated updates to continue; malformed, ambiguous, or failed remote tag resolution stops before manifest or lockfile writes. Revised [req-rs-011], [req-rs-012], and [req-rs-015] for bounded manifest rewrite, scoped operation, advisory tag provenance, and network-free replay. Section 5.2, Section 5.6, Section 7.11, Section 11.3.2, Appendix C, and conformance coverage updated. Statement count: 119 -> 120 (115 MUST, 5 SHOULD). | +| 0.1.38 | 2026-09-01 | Defensive amendment of [req-lk-005] (no new normative statement; count remains 120 (115 MUST, 5 SHOULD)): `generated_at` is optional advisory metadata, new lockfiles omit it by default, and later writes preserve an existing omission unless explicitly configured otherwise. | +| 0.1.39 | 2026-09-01 | Spec-citation fold for user-scoped direct MCP target selection (closes #2548 Mode-B silent-extension gate). Added [req-tg-014] (Section 8.5.8, consumer MUST): explicit selection, the user-scope manifest, configured user default, and user-scope runtime discovery form one precedence chain; project-only signals cannot constrain final discovery; and a selected set with no user-capable runtime fails before user manifest, lockfile, or target-config mutation. Section 8.7, Section 11.3.2, and Appendix C updated. Statement count: 120 -> 121 (116 MUST, 5 SHOULD). | +| 0.1.40 | 2026-09-07 | Spec-citation fold for dependency-policy identity casing in PR #2706. Added [req-pl-018] (Section 6.3.1, governance MUST) and extended [req-rs-016] clause (3): dependency allow, deny, and exact require operands use the documented per-host repository case rule, while registry-sourced repository coordinates are case-insensitive regardless of host; case normalization is ASCII-only, is bounded identically on both operands, stops at recursive-glob ambiguity, and does not cross virtual-path, ref, registry-name, MCP-name, unmanaged-path, or case-sensitive host/source boundaries; deny precedence is unchanged. Defined the policy glob grammar, documented byte-exact Section 6.4 merge behavior, and added the threat mapping. Classified this as a non-breaking correction of previously unspecified evaluation behavior under Section 9.2: existing lowercase workarounds remain matching; on registry sources and hosts documented as case-insensitive, case-variant allow entries can newly match, deny entries can newly enforce, and exact require entries can newly be satisfied, so those policies should be re-audited. Sections 1.3, 6.3.1, 6.3.5, 6.4, 6.5, 6.9, 7.2, 9.2, 10.8, 10.11, 11.2, and 11.3.4, Appendix C, and conformance coverage updated. Statement count: 121 -> 122 (117 MUST, 5 SHOULD). | + +Errata (none at publication). + +--- + +## Appendix E. Editorial reconciliation notes + +This appendix collects editorial notes that were inlined in the +v0.1 first draft. They are non-normative; the section bodies they +reference remain authoritative. + +**E.1 Manifest top-level `type` field.** [Section 4.2.2](#422-type-advisory) +defines `type` (with values `instructions`, `skill`, `hybrid`, or +`prompts`) as informational in this revision; it exists so future minor +revisions can attach normative semantics (for example, packaging +filters per type) without a breaking schema change. Consumers +MUST ignore the value. + +**E.2 Target identifier reservation.** The target identifiers +enumerated in the OpenAPM Target Registry companion are reserved +in the current namespace; vendor extensions MUST use the +`x--` pattern of [req-tg-004](#req-tg-004) to avoid +collision. + +**E.3 Lockfile `lockfile_version: "2"` adoption.** Once a +conforming consumer has written `"2"`, it MUST NOT demote to +`"1"` on subsequent rewrites (see +[req-lk-002](#req-lk-002)). The motivation is auditability: a +silently-demoted lockfile would lose the registry-binding +metadata that prompted the v2 upgrade. + +**E.4 `resolved_at` non-stability.** The lockfile field +`resolved_at` is advisory and MAY vary across re-resolutions; it +is excluded from canonical-emission stability checks per +[Section 5.6](#56-git-semver-fields-constraint-resolved_tag-resolved_at). Round- +trip conformance ([req-cf-001](#req-cf-001)) treats this field as +permitted-to-vary. diff --git a/docs/src/content/docs/troubleshooting/migration.md b/docs/src/content/docs/troubleshooting/migration.md index 3993dd6380..83aa00c02b 100644 --- a/docs/src/content/docs/troubleshooting/migration.md +++ b/docs/src/content/docs/troubleshooting/migration.md @@ -105,6 +105,30 @@ An interrupted case-only rename can leave a hidden contents before renaming it back or removing it; APM does not delete an unverified recovery entry automatically. +### Rejected dependency aliases + +APM now rejects aliases `.` and `..`. The alias directory must resolve strictly +under `apm_modules/`, including through symlinks. Safe dotted names such as +`.safe`, `safe.`, `foo..bar`, and `my-skill.v2` remain valid; see the +[alias field reference](../../reference/manifest-schema/#412-object-form). + +1. In the declaring `apm.yml`, replace `alias: .` or `alias: ..` with + `alias: my-skill.v2`. Leave the source unchanged, including local `../` paths. +2. Run `apm install`. +3. Review `apm.lock.yaml`, installed package contents, and generated deployment + changes before committing. Do not assume previously incorrect metadata is + automatically repaired. + +Do not remove or prune the rejected alias path: it can refer to +`apm_modules/` itself or its parent. + +Valid aliases now persist in the lockfile so audit replay, hooks, MCP, +and cleanup use the same directory as installation. For an older aliased +install, run `apm install` and review the new `alias` metadata before +committing. Keep using the updated CLI: older readers may preserve this +field without honoring its placement. A missing alias never defaults to +the package's inventory `name`. + ## 4. Compile strategy migration The compile step writes per-target output (e.g. `.github/copilot-instructions.md`, `.claude/`, `.cursor/rules/`). Some targets support both a single-file (monolithic) layout and a per-primitive (distributed) layout. diff --git a/packages/apm-guide/.apm/skills/apm-usage/dependencies.md b/packages/apm-guide/.apm/skills/apm-usage/dependencies.md index 7841cbf178..7ad7616691 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/dependencies.md +++ b/packages/apm-guide/.apm/skills/apm-usage/dependencies.md @@ -173,12 +173,17 @@ instead so `@` remains reserved for git usernames and version syntax. | `git` | REQUIRED | Clone URL (HTTPS, SSH, or FQDN shorthand). The literal `parent` inherits the consuming package's repo. | | `path` | OPTIONAL | Subdirectory or file within the repo (virtual package). | | `ref` | OPTIONAL | Branch, tag, or commit SHA. | -| `alias` | OPTIONAL | Install under a custom directory name (`^[a-zA-Z0-9._-]+$`). | +| `alias` | OPTIONAL | Install-directory name (`^[a-zA-Z0-9._-]+$`), excluding exactly `.` and `..`. Dotted names such as `my-skill.v2` are valid. | | `type` | OPTIONAL | Set to `gitlab` for self-managed GitLab on a bespoke hostname. Generic hosts do not receive APM-managed PATs on HTTP file reads. See the [lockfile spec](https://microsoft.github.io/apm/reference/lockfile-spec/#lockfile-identity-keys) for keying rules. | | `allow_insecure` | OPTIONAL | Manifest-side approval for an `http://` dependency; the install command still requires its separate insecure-host opt-in. | | `skills` | OPTIONAL | Select deployed skills, not a repo slice; use `path` for a subdirectory. | | `targets` | OPTIONAL | Consumer-side harness subset for that dependency's target-scoped primitives. Non-empty list of target names. | +Aliases are persisted in `apm.lock.yaml` for replay and removal without changing +source identity or local `../sibling` source anchors. After upgrading an older +aliased install, run `apm install` and review the updated lockfile; use the +updated CLI for subsequent replay and cleanup. + Git [skill collections](../../../../../docs/src/content/docs/reference/package-types.md) with `skills//SKILL.md` support `skills: [name]` without root `apm.yml` or `SKILL.md`. @@ -208,7 +213,7 @@ and `alias`. | Field | Required | Description | |-------|----------|-------------| | `path` | REQUIRED | Filesystem path (must start with `./`, `../`, `/`, or `~/`). | -| `alias` | OPTIONAL | Install under a custom directory name (`^[a-zA-Z0-9._-]+$`). | +| `alias` | OPTIONAL | Install-directory name (`^[a-zA-Z0-9._-]+$`), excluding exactly `.` and `..`. Dotted names such as `my-skill.v2` are valid. Local `../` sources remain supported. | | `skills` | OPTIONAL | Consumer-side skill subset for that dependency. Non-empty list of skill names. | | `targets` | OPTIONAL | Consumer-side harness subset for that dependency's target-scoped primitives. Non-empty list of target names. | @@ -336,7 +341,7 @@ Object-form fields: | `version` | yes | Exact semver version or semver range (e.g. `1.4.0`, `^2.0.0`, `>=1.2.0 <2.0.0`). Non-semver refs (labels like `stable`/`latest`, `v`-prefixed tags, branch names, SHAs) are rejected when routed to a registry | | `registry` | no | Name from the merged registry map; defaults to the effective default | | `path` | no | Sub-path to a file or directory within the published package | -| `alias` | no | Local alias controlling the install directory name | +| `alias` | no | Install-directory name (`^[a-zA-Z0-9._-]+$`), excluding exactly `.` and `..`. Dotted names such as `my-skill.v2` are valid. | Routing rules when a default registry is active: 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 3a4fa46d4f..2b5df70015 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md +++ b/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md @@ -44,10 +44,10 @@ backfills one from the other: - `name` and `version` must be non-empty strings. Quote numeric versions so YAML does not parse them as numbers. -Use the standard `$schema` key when authoring against normative OpenAPM v0.1: -`https://microsoft.github.io/apm/specs/schemas/manifest-v0.1.schema.json`. -Omitting `$schema` selects APM's current working draft. Unknown schema -identities fail closed rather than being interpreted as the working draft. +For alias-aware OpenAPM v0.1, amendment 0.1.41 selects +`https://microsoft.github.io/apm/specs/schemas/manifest-v0.1.41.schema.json` +as the `$schema` identity; see [schema status](../../../../../docs/src/content/docs/specs/openapm-v0.1.md#appendix-a-normative-json-schemas-inline). +Omit `$schema` for APM's current working draft. Unknown identities fail closed. Populate both descriptions when you ship a HYBRID package. `apm pack` warns when `apm.yml.description` is missing so listings do not diff --git a/scripts/architecture_linter/checks/contracts_test_taxonomy.py b/scripts/architecture_linter/checks/contracts_test_taxonomy.py index 785c8a4064..bf3282cbda 100644 --- a/scripts/architecture_linter/checks/contracts_test_taxonomy.py +++ b/scripts/architecture_linter/checks/contracts_test_taxonomy.py @@ -469,6 +469,74 @@ def check_dependency_identity(provider: FactsProvider) -> tuple[Violation, ...]: return tuple(failures) findings: list[Violation] = [] + alias_consumers = { + "src/apm_cli/deps/lockfile.py": ( + "self.alias = parse_alias_override(self.alias)", + 'result["alias"] = self.alias', + 'alias=data.get("alias")', + "alias=dep_ref.alias", + "alias=self.alias", + ), + _REFERENCE_OWNER: ("alias = parse_alias_override(alias)",), + "src/apm_cli/models/dependency/registry_entry.py": ( + 'alias = parse_alias_override(entry.get("alias"))', + ), + "src/apm_cli/models/dependency/object_fields.py": ( + 'validate_path_segments(alias, context="dependency alias")', + ), + _MATERIALIZATION_OWNER: ( + "alias = parse_alias_override(dependency.alias)", + "if alias is not None:", + "resolved = ensure_path_within(result, apm_modules_dir)", + "if resolved == ensure_path_within(apm_modules_dir, apm_modules_dir):", + "if dependency.alias is not None or not dependency.has_case_insensitive_repo_identity:", + ), + "src/apm_cli/install/phases/download.py": ( + "_pd_path = _pd_ref.get_install_path(apm_modules_dir)", + ), + "src/apm_cli/install/phases/integrate.py": ( + "install_path = dep_ref.get_install_path(apm_modules_dir)", + ), + _RESOLVE_PHASE: ( + " cache_validation_callback=partial(", + "validate_cached_legacy_plugin,", + ), + "src/apm_cli/deps/apm_resolver.py": ( + "if parent_dep.alias:", + "replace(parent_dep, alias=None).get_install_path(self._apm_modules_dir)", + "repo_root, parent_source = self._remote_source_paths_for_parent(", + "self._cache_validation_callback(install_path, dep_ref.get_unique_key())", + "and self._download_dedup_key(dep_ref, parent_pkg) not in self._downloaded_packages", + ), + "src/apm_cli/install/legacy_plugin_compat.py": ( + "plugin_json_path = validate_cached_legacy_plugin(", + ), + } + for path, required in alias_consumers.items(): + facts, errors = _facts_for(provider, path, rule_id) + findings.extend(errors) + source = "\n".join(_lines(facts)) + reuse_order = ( + source.find("if dep_ref.is_local or not install_path.exists()"), + source.find("self._cache_validation_callback(install_path,"), + source.find("materialize_marketplace_manifest(dep_ref, install_path)"), + ) + misplaced_cache_validation = ( + path == "src/apm_cli/deps/apm_resolver.py" + and not (0 <= reuse_order[0] < reuse_order[1] < reuse_order[2]) + ) or (path == _RESOLVE_PHASE and "validate_cached_legacy_plugin(" in source) + if not errors and ( + any(not _present(facts, needle) for needle in required) + or _present_re(facts, re.compile(r"/\s*\w+\.alias\b")) + or misplaced_cache_validation + ): + findings.append( + _summary( + rule_id, + path, + "Dependency aliases must use shared validation and strict materialization ownership", + ) + ) unique_key_body = _awk_body( identity, re.compile(r"^def build_dependency_unique_key\("), re.compile(r"^def ") ) diff --git a/scripts/architecture_linter/checks/transport_network_and_runtime.py b/scripts/architecture_linter/checks/transport_network_and_runtime.py index 124f15d90f..2d1cf60f87 100644 --- a/scripts/architecture_linter/checks/transport_network_and_runtime.py +++ b/scripts/architecture_linter/checks/transport_network_and_runtime.py @@ -147,6 +147,26 @@ def _check_ref_freshness(provider: FactsProvider) -> tuple[Violation, ...]: "tiered_ref_resolver must own RefFreshnessPolicy and its install policy", ) ) + findings.extend( + _require_subs( + provider, + inv, + _RID_FRESHNESS, + _TIERED, + ("def remotely_resolved(", "self.freshness_policy.requires_remote"), + "persistent ref observations must be authorized by the freshness owner", + ) + ) + findings.extend( + _require_subs( + provider, + inv, + _RID_FRESHNESS, + "src/apm_cli/deps/github_downloader.py", + ("resolver.remotely_resolved(dep_ref, locked_sha)",), + "persistent ref writes must consult remote resolution provenance", + ) + ) findings.extend( _require_subs( provider, diff --git a/src/apm_cli/cache/git_cache.py b/src/apm_cli/cache/git_cache.py index bd0857de38..4c635c8b6e 100644 --- a/src/apm_cli/cache/git_cache.py +++ b/src/apm_cli/cache/git_cache.py @@ -33,6 +33,7 @@ import subprocess from pathlib import Path +from ..utils.atomic_io import atomic_write_text from ..utils.git_sparse import apply_sparse_cone, repair_dangling_cone_symlinks from ..utils.path_security import ensure_path_within from .integrity import verify_checkout_sha @@ -232,6 +233,31 @@ def get_checkout( promisor_url=url if use_partial else None, ) + def _resolved_ref_path(self, url: str, ref: str) -> Path: + digest = hashlib.sha256(ref.encode("utf-8")).hexdigest() + path = self._db_root / f"{cache_shard_key(url)}-{digest}.ref" + ensure_path_within(path, self._db_root) + return path + + def remember_resolved_ref(self, url: str, ref: str, sha: str) -> None: + """Persist an authorized remote observation independently of bare flavor.""" + if not ref or not _SHA_RE.fullmatch(sha): + raise ValueError("A remote ref observation requires a ref and full SHA") + path = self._resolved_ref_path(url, ref) + with shard_lock(path): + atomic_write_text(path, sha.lower(), new_file_mode=0o600) + + def read_resolved_ref(self, url: str, ref: str) -> tuple[bool, str | None]: + """Return receipt presence and SHA; corrupt receipts must not revive old refs.""" + path = self._resolved_ref_path(url, ref) + try: + sha = path.read_text(encoding="ascii") + except FileNotFoundError: + return False, None + except (OSError, UnicodeError): + return True, None + return True, sha if _SHA_RE.fullmatch(sha) else None + def _record_checkout_access(self, checkout_dir: Path) -> Path: """Record successful reuse of a finalized checkout under its shard lock.""" # Pruning ages the shared SHA root, not individual checkout variants. @@ -876,6 +902,8 @@ def get_cache_stats(self) -> dict[str, int]: if entry.is_dir(follow_symlinks=False) and not entry.name.endswith(".lock"): db_count += 1 total_size += _dir_size(Path(entry.path)) + elif entry.name.endswith(".ref") and entry.is_file(follow_symlinks=False): + total_size += entry.stat(follow_symlinks=False).st_size if self._checkouts_root.is_dir(): for shard_entry in os.scandir(str(self._checkouts_root)): diff --git a/src/apm_cli/commands/deps/_utils.py b/src/apm_cli/commands/deps/_utils.py index 1cb909f006..0be42299f7 100644 --- a/src/apm_cli/commands/deps/_utils.py +++ b/src/apm_cli/commands/deps/_utils.py @@ -12,26 +12,27 @@ def _scan_installed_packages(apm_modules_dir: Path) -> list: """Scan *apm_modules_dir* for installed package paths. Walks the tree to find top-level directories containing ``apm.yml`` or - ``.apm``, supporting GitHub (2-level), ADO (3-level), and subdirectory - packages. Package manifests nested below another package are part of that + ``.apm``, supporting aliases (1-level), GitHub (2-level), ADO (3-level), + and subdirectory packages. Manifests nested below another package are part of that parent package and are excluded. Returns: - List of ``"owner/repo"`` or ``"org/project/repo"`` path keys. + List of installed relative paths, including flattened aliases. """ installed: list = [] if not apm_modules_dir.exists(): return installed for candidate in apm_modules_dir.rglob("*"): - if not candidate.is_dir() or candidate.name.startswith("."): + if candidate.is_symlink() or not candidate.is_dir(): + continue + if candidate.name.startswith(".") and candidate.parent != apm_modules_dir: continue if not ((candidate / APM_YML_FILENAME).exists() or (candidate / APM_DIR).exists()): continue if _is_nested_under_package(candidate, apm_modules_dir): continue rel_parts = candidate.relative_to(apm_modules_dir).parts - if len(rel_parts) >= 2: - installed.append("/".join(rel_parts)) + installed.append("/".join(rel_parts)) return installed diff --git a/src/apm_cli/deps/apm_resolver.py b/src/apm_cli/deps/apm_resolver.py index ab6a826f75..9253d8c93e 100644 --- a/src/apm_cli/deps/apm_resolver.py +++ b/src/apm_cli/deps/apm_resolver.py @@ -5,7 +5,7 @@ import os import threading from collections import deque -from collections.abc import Iterable +from collections.abc import Callable, Iterable from concurrent.futures import ThreadPoolExecutor from dataclasses import fields, is_dataclass, replace from pathlib import Path, PureWindowsPath @@ -99,6 +99,7 @@ def __init__( update_refs: bool = False, existing_lockfile: "LockFile | None" = None, activation_callback: ActivationCallback | None = None, + cache_validation_callback: Callable[[Path, str], Path | None] | None = None, ): """Initialize the resolver with maximum recursion depth. @@ -127,12 +128,15 @@ def __init__( must re-enter the download callback. activation_callback: Optional callback that publishes a downloaded candidate only after this resolver validates it. + cache_validation_callback: Optional read-only admission check for reused + bytes, after the fetch decision and before package normalization. """ self.max_depth = max_depth self._apm_modules_dir: Path | None = apm_modules_dir self._project_root: Path | None = None self._download_callback = download_callback self._activation_callback = activation_callback + self._cache_validation_callback = cache_validation_callback self._update_refs = update_refs self._existing_lockfile = existing_lockfile # Whether ``download_callback`` accepts ``parent_pkg`` (added in #857). @@ -405,24 +409,30 @@ def _portable_anchor_identity(anchored: Path, base: Path | None) -> str: return anchored.resolve().as_posix() return portable_relpath(anchored, base) - def _remote_repo_root_for_parent( + def _remote_source_paths_for_parent( self, parent_dep: DependencyReference, parent_pkg: APMPackage, - ) -> Path: - """Return the on-disk clone root for a remote parent package.""" + ) -> tuple[Path, Path]: + """Return contained repository and package source-coordinate anchors.""" if self._apm_modules_dir is None or parent_pkg.source_path is None: raise PathTraversalError( "remote parent package has no source path to anchor local path" ) source_path = ensure_path_within(parent_pkg.source_path, self._apm_modules_dir) + if parent_dep.alias: + # Aliases flatten materialization, not authenticated repository coordinates. + source_path = ensure_path_within( + replace(parent_dep, alias=None).get_install_path(self._apm_modules_dir), + self._apm_modules_dir, + ) repo_root = source_path if parent_dep.virtual_path: validate_path_segments(parent_dep.virtual_path, context="virtual_path") for _segment in parent_dep.virtual_path.replace("\\", "/").split("/"): if _segment: repo_root = repo_root.parent - return ensure_path_within(repo_root, self._apm_modules_dir) + return ensure_path_within(repo_root, self._apm_modules_dir), source_path def _expand_remote_parent_local_path( self, @@ -448,8 +458,8 @@ def _expand_remote_parent_local_path( if self._is_absolute_local_path(local_str): raise PathTraversalError("absolute paths inside remote packages are not allowed") - repo_root = self._remote_repo_root_for_parent(parent_dep, parent_pkg) - parent_source = ensure_path_within(parent_pkg.source_path, repo_root) + repo_root, parent_source = self._remote_source_paths_for_parent(parent_dep, parent_pkg) + parent_source = ensure_path_within(parent_source, repo_root) local_path = Path(local_str.replace("\\", "/")) resolved = ensure_path_within(parent_source / local_path, repo_root) virtual_path = resolved.relative_to(repo_root).as_posix() @@ -1191,6 +1201,12 @@ def _try_load_dependency_package( if not install_path.exists(): return None + if ( + self._cache_validation_callback is not None + and self._download_dedup_key(dep_ref, parent_pkg) not in self._downloaded_packages + ): + self._cache_validation_callback(install_path, dep_ref.get_unique_key()) + materialize_marketplace_manifest(dep_ref, install_path) # Native Agent Plugins must retain their projected compatibility package @@ -1445,7 +1461,8 @@ def _compute_dep_source_path( For LOCAL deps we return the *original* user source directory so that transitive ``../sibling`` references inside its apm.yml resolve as a developer reading the file expects (#857). For REMOTE deps we return - the clone location under apm_modules. + the materialized package location; repository-relative expansion derives + separate source-coordinate anchors. """ if dep_ref.is_local and dep_ref.local_path: local = Path(dep_ref.local_path).expanduser() diff --git a/src/apm_cli/deps/github_downloader.py b/src/apm_cli/deps/github_downloader.py index aa355ee454..386353fc33 100644 --- a/src/apm_cli/deps/github_downloader.py +++ b/src/apm_cli/deps/github_downloader.py @@ -381,7 +381,7 @@ def _checkout(token: str | None, env: dict[str, str]) -> Path: sparse_paths=sparse_paths, ) - return self.auth_resolver.try_with_fallback( + checkout = self.auth_resolver.try_with_fallback( dep_ref.host or default_host(), _checkout, org=org, @@ -392,13 +392,23 @@ def _checkout(token: str | None, env: dict[str, str]) -> Path: base_env=self.git_env, ) - return cache.get_checkout( - repository_url, - ref, - locked_sha=locked_sha, - env=self._cache_git_env(dep_ref), - sparse_paths=sparse_paths, - ) + else: + checkout = cache.get_checkout( + repository_url, + ref, + locked_sha=locked_sha, + env=self._cache_git_env(dep_ref), + sparse_paths=sparse_paths, + ) + resolver = getattr(self, "_tiered_resolver", None) + if ( + resolver is not None + and locked_sha + and dep_ref.reference + and resolver.remotely_resolved(dep_ref, locked_sha) is True + ): + cache.remember_resolved_ref(repository_url, dep_ref.reference, locked_sha) + return checkout def _setup_git_environment(self) -> dict[str, Any]: """Set up Git environment with authentication using centralized token manager. diff --git a/src/apm_cli/deps/lockfile.py b/src/apm_cli/deps/lockfile.py index 374209eef7..e69a279109 100644 --- a/src/apm_cli/deps/lockfile.py +++ b/src/apm_cli/deps/lockfile.py @@ -18,6 +18,7 @@ from ..core.host_providers import accepted_host_types from ..models.apm_package import DependencyReference from ..models.dependency.identity import normalize_package_repo_url +from ..models.dependency.object_fields import parse_alias_override from ..models.dependency.reference import ( build_canonical_dependency_string, build_dependency_unique_key, @@ -275,6 +276,7 @@ class LockedDependency: # See to_dict/from_dict and the supply-chain boundary note in the lockfile # spec. Omitted from the serialized entry when absent. name: str | None = None + alias: 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 @@ -283,6 +285,14 @@ class LockedDependency: def __post_init__(self) -> None: """Separate canonical lock identity from materialization spelling.""" + try: + self.alias = parse_alias_override(self.alias) + except ValueError as exc: + raise ValueError( + f"Invalid lockfile dependency alias {self.alias!r}. Restore a known-good " + "apm.lock.yaml or correct this entry's alias to its intended placement. " + "Do not delete content at a rejected alias destination." + ) from exc original_repo_url = self.repo_url canonical_repo_url = normalize_package_repo_url( self.repo_url, @@ -342,6 +352,8 @@ def to_dict(self) -> dict[str, Any]: result: dict[str, Any] = {"repo_url": self.repo_url} if self.materialization_repo_url: result["materialization_repo_url"] = self.materialization_repo_url + if self.alias is not None: + result["alias"] = self.alias if self.name is not None: result["name"] = self.name if self.host: @@ -458,6 +470,7 @@ def from_dict(cls, data: dict[str, Any]) -> LockedDependency: _known_keys = { "repo_url", "materialization_repo_url", + "alias", "host", "host_type", "port", @@ -506,6 +519,7 @@ def from_dict(cls, data: dict[str, Any]) -> LockedDependency: return cls( repo_url=data["repo_url"], materialization_repo_url=data.get("materialization_repo_url"), + alias=data.get("alias"), host=data.get("host"), host_type=host_type, port=port, @@ -651,6 +665,7 @@ def from_dependency_ref( ) return cls( repo_url=canonical_repo_url, + alias=dep_ref.alias, materialization_repo_url=( dep_ref.repo_url if dep_ref.repo_url != canonical_repo_url else None ), @@ -712,6 +727,7 @@ def to_dependency_ref(self) -> DependencyReference: ref = self.version if (is_registry and self.version) else self.resolved_ref return DependencyReference( repo_url=self.materialization_repo_url or self.repo_url, + alias=self.alias, host=self.host, host_type=self.host_type, port=self.port, diff --git a/src/apm_cli/deps/tiered_ref_resolver.py b/src/apm_cli/deps/tiered_ref_resolver.py index 35dd8df23c..9fed8a79a3 100644 --- a/src/apm_cli/deps/tiered_ref_resolver.py +++ b/src/apm_cli/deps/tiered_ref_resolver.py @@ -14,9 +14,9 @@ * **L1 CommitsAPI** -- cheap ``GET /repos/.../commits/{ref}`` against the GitHub-family host_backend, with ``Accept: application/vnd.github.sha`` + optional ``HttpCache`` ETag. ~1 RTT. -* **L2 BareRevParse** -- if the cross-run :class:`GitCache` already has a - bare clone of the URL, ``git rev-parse refs/heads/REF`` against it. - Zero network. Catches the second-run case. +* **L2 BareRevParse** -- read a recorded current-remote observation first; + if absent, use ``git rev-parse`` against an existing cached bare clone. + Zero network. Catches the second-run case without reviving superseded refs. * **L3 LegacyClone** -- delegates to the legacy :meth:`GitReferenceResolver.resolve` (shallow clone + introspect). Behaviourally identical to the pre-#1369 path; always succeeds or @@ -266,10 +266,11 @@ def try_resolve(self, dep_ref: DependencyReference, ref: str) -> str | None: class L2BareRevParse: - """Resolve by ``git rev-parse`` against an already-cached bare clone. + """Resolve from a remote-observation receipt, then a cached bare clone. - No network. Hits only when :class:`GitCache` has a bare clone of the - URL from a previous run. Cheap follow-up tier after L0/L1 miss -- + No network. A corrupt receipt misses rather than reviving an older bare + ref. Without a receipt, a cached bare clone supplies the legacy fallback. + Cheap follow-up tier after L0/L1 miss -- catches repeat reproducible installs where the bare exists but the cheap API is unavailable (e.g. ADO). Current-state commands exclude this tier. """ @@ -285,6 +286,10 @@ def try_resolve(self, dep_ref: DependencyReference, ref: str) -> str | None: if _SHA_RE.match(ref): return ref.lower() + recorded, sha = self._git_cache.read_resolved_ref(dep_ref.to_github_url(), ref) + if recorded: + return sha + try: from ..cache.url_normalize import cache_shard_key except Exception: @@ -412,6 +417,7 @@ def __init__( self.freshness_policy = freshness_policy self._coalesce: dict[tuple[str, str], threading.Event] = {} self._coalesce_lock = threading.Lock() + self._remote_resolutions: dict[tuple[str, str], str] = {} # Diagnostics: counts per tier across the run. Read by tests. self.stats: dict[str, int] = {tier.name: 0 for tier in tiers} self.stats["coalesced"] = 0 @@ -419,6 +425,12 @@ def __init__( # so verbose tier stats do not inflate the commits-API count. self.stats["sha_passthrough"] = 0 + def remotely_resolved(self, dep_ref: DependencyReference, sha: str) -> bool: + """Authorize persistence only for a current-remote tier's exact observation.""" + key = (_repository_cache_identity(dep_ref), dep_ref.reference or "") + with self._coalesce_lock: + return self._remote_resolutions.get(key) == sha + def seed(self, repo_ref: str | DependencyReference, ref: str, sha: str) -> bool: """Pre-populate the L0 per-run cache with a known ``ref -> sha``. @@ -518,6 +530,13 @@ def _dispatch(self, dep_ref: DependencyReference, ref: str) -> str | None: if sha and _SHA_RE.match(sha): self.stats[tier.name] = self.stats.get(tier.name, 0) + 1 self._last_tier = tier.name + if self.freshness_policy.requires_remote and tier.name in ( + "commits_api", + "legacy_clone", + ): + key = (_repository_cache_identity(dep_ref), ref) + with self._coalesce_lock: + self._remote_resolutions[key] = sha.lower() return sha.lower() return None diff --git a/src/apm_cli/install/helpers/ref_reuse.py b/src/apm_cli/install/helpers/ref_reuse.py index 4127c6a6bf..cb551e4e0a 100644 --- a/src/apm_cli/install/helpers/ref_reuse.py +++ b/src/apm_cli/install/helpers/ref_reuse.py @@ -18,6 +18,7 @@ if TYPE_CHECKING: from apm_cli.deps.github_downloader import GitHubPackageDownloader from apm_cli.deps.transport_selection import ProtocolPreference, TransportSelector + from apm_cli.install.context import InstallContext from apm_cli.models.dependency.reference import DependencyReference RefResolverCacheKey = tuple[ @@ -29,6 +30,14 @@ _UNRESOLVED_AUTH_CONTEXT = object() +def requires_remote_ref_resolution(ctx: InstallContext) -> bool: + """Return the configured policy decision or fail before resolution.""" + policy = ctx.ref_freshness_policy + if policy is None: + raise RuntimeError("Ref freshness policy was not configured") + return policy.requires_remote + + def _token_fingerprint(token: str | None) -> str | None: """Return a non-reversible fingerprint of ``token`` for use as a cache key. diff --git a/src/apm_cli/install/legacy_plugin_compat.py b/src/apm_cli/install/legacy_plugin_compat.py index 98c201dd22..b662ad03fd 100644 --- a/src/apm_cli/install/legacy_plugin_compat.py +++ b/src/apm_cli/install/legacy_plugin_compat.py @@ -91,6 +91,22 @@ def upgrade_cached_legacy_plugin( fetched_this_run: bool, ) -> APMPackage | None: """Repair receipt-less 0.28 plugin metadata before cached integration.""" + plugin_json_path = validate_cached_legacy_plugin( + package_path, dep_key, lockfile=lockfile, fetched_this_run=fetched_this_run + ) + if plugin_json_path is None: + return None + return _normalize_legacy_plugin_transactionally(package_path, dep_key, plugin_json_path) + + +def validate_cached_legacy_plugin( + package_path: Path, + dep_key: str, + *, + lockfile: LockFile | None, + fetched_this_run: bool, +) -> Path | None: + """Check cached legacy metadata before any resolver or integration normalization.""" locked_dependency = lockfile.get_dependency(dep_key) if lockfile is not None else None if ( fetched_this_run @@ -137,11 +153,7 @@ def upgrade_cached_legacy_plugin( f"content hash mismatch (expected {expected_hash}, got {actual_hash})", ) - return _normalize_legacy_plugin_transactionally( - package_path, - dep_key, - evidence.plugin_json_path, - ) + return evidence.plugin_json_path def _normalize_legacy_plugin_transactionally( diff --git a/src/apm_cli/install/phases/download.py b/src/apm_cli/install/phases/download.py index 5a171520fb..6c46c6f5bf 100644 --- a/src/apm_cli/install/phases/download.py +++ b/src/apm_cli/install/phases/download.py @@ -40,6 +40,8 @@ def run(ctx: InstallContext) -> None: update_refs = ctx.update_refs parallel_downloads = ctx.parallel_downloads apm_modules_dir = ctx.apm_modules_dir + if apm_modules_dir is None: + raise RuntimeError("Resolution must set apm_modules_dir before download") downloader = ctx.downloader callback_downloaded = ctx.callback_downloaded callback_failures = ctx.callback_failures @@ -54,11 +56,7 @@ def run(ctx: InstallContext) -> None: _need_download = [] for _pd_ref in deps_to_install: _pd_key = _pd_ref.get_unique_key() - _pd_path = ( - (apm_modules_dir / _pd_ref.alias) - if _pd_ref.alias - else _pd_ref.get_install_path(apm_modules_dir) - ) + _pd_path = _pd_ref.get_install_path(apm_modules_dir) # Skip local packages -- they are copied, not downloaded if _pd_ref.is_local: continue diff --git a/src/apm_cli/install/phases/integrate.py b/src/apm_cli/install/phases/integrate.py index b9632ef81c..22a720a871 100644 --- a/src/apm_cli/install/phases/integrate.py +++ b/src/apm_cli/install/phases/integrate.py @@ -585,6 +585,8 @@ def run(ctx: InstallContext) -> None: # ------------------------------------------------------------------ deps_to_install = ctx.deps_to_install apm_modules_dir = ctx.apm_modules_dir + if apm_modules_dir is None: + raise RuntimeError("Resolution must set apm_modules_dir before integration") # Direct dep keys: used to distinguish direct vs transitive failures # so direct failures can be surfaced immediately. @@ -614,12 +616,7 @@ def run(ctx: InstallContext) -> None: # e.g., microsoft/apm-sample-package -> apm_modules/microsoft/apm-sample-package/ # For virtual packages: owner/repo/prompts/file.prompt.md -> apm_modules/owner/repo-file/ # For subdirectory packages: owner/repo/subdir -> apm_modules/owner/repo/subdir/ - if dep_ref.alias: - # If alias is provided, use it directly (assume user handles namespacing) - install_path = apm_modules_dir / dep_ref.alias - else: - # Use the canonical install path from DependencyReference - install_path = dep_ref.get_install_path(apm_modules_dir) + install_path = dep_ref.get_install_path(apm_modules_dir) # Skip deps that already failed during BFS resolution callback # to avoid a duplicate error entry in diagnostics. diff --git a/src/apm_cli/install/phases/resolve.py b/src/apm_cli/install/phases/resolve.py index fdb0735d35..fc7e0aec8f 100644 --- a/src/apm_cli/install/phases/resolve.py +++ b/src/apm_cli/install/phases/resolve.py @@ -25,6 +25,9 @@ from apm_cli.install.helpers.ref_reuse import ( maybe_resolve_git_semver as _maybe_resolve_git_semver, ) +from apm_cli.install.helpers.ref_reuse import ( + requires_remote_ref_resolution as _requires_remote_ref_resolution, +) from apm_cli.install.helpers.ref_seed import seed_ref_resolver_from_lockfile from apm_cli.install.transaction import resolution_for_context from apm_cli.models.apm_package import GitReferenceType, ResolvedReference @@ -323,14 +326,6 @@ def _fail_on_resolution_errors(ctx: InstallContext, dependency_graph) -> None: raise RuntimeError(f"Dependency resolution failed: {joined_errors}") -def _requires_remote_ref_resolution(ctx: InstallContext) -> bool: - """Return the configured policy decision or fail before resolution.""" - policy = ctx.ref_freshness_policy - if policy is None: - raise RuntimeError("Ref freshness policy was not configured") - return policy.requires_remote - - def _attach_resolver_marketplace_provenance( ctx: InstallContext, resolver, @@ -738,6 +733,8 @@ def download_callback(dep_ref, modules_dir, parent_chain="", parent_pkg=None): # ------------------------------------------------------------------ # 6. Resolver creation + dependency resolution # ------------------------------------------------------------------ + from apm_cli.install.legacy_plugin_compat import validate_cached_legacy_plugin + resolver = APMDependencyResolver( apm_modules_dir=ctx.apm_modules_dir, download_callback=download_callback, @@ -752,6 +749,11 @@ def download_callback(dep_ref, modules_dir, parent_chain="", parent_pkg=None): auth_resolver=ctx.auth_resolver, update_refs=update_refs, existing_lockfile=existing_lockfile, + cache_validation_callback=partial( + validate_cached_legacy_plugin, + lockfile=existing_lockfile, + fetched_this_run=False, + ), ) # Resolver reads ``/apm.yml``. Preserve the original diff --git a/src/apm_cli/models/dependency/materialization.py b/src/apm_cli/models/dependency/materialization.py index 4266defed7..7b060656f5 100644 --- a/src/apm_cli/models/dependency/materialization.py +++ b/src/apm_cli/models/dependency/materialization.py @@ -10,7 +10,8 @@ from pathlib import Path, PurePosixPath from typing import TYPE_CHECKING, Protocol -from ...utils.path_security import ensure_path_within, validate_path_segments +from ...utils.path_security import PathTraversalError, ensure_path_within, validate_path_segments +from .object_fields import parse_alias_override if TYPE_CHECKING: from ...install.resolution_staging import ResolutionStagingSession @@ -113,6 +114,24 @@ def build_materialization_path( f"'{dependency.marketplace_plugin_name}@{dependency.marketplace_name}'" ) + alias = parse_alias_override(dependency.alias) + if alias is not None: + result = apm_modules_dir / alias + try: + resolved = ensure_path_within(result, apm_modules_dir) + except PathTraversalError as exc: + raise PathTraversalError( + f"Invalid dependency alias {alias!r}: unsafe destination {result}. " + "Inspect the alias path without removing its target, choose a separate " + "package directory, and rerun apm install." + ) from exc + if resolved == ensure_path_within(apm_modules_dir, apm_modules_dir): + raise PathTraversalError( + f"Invalid dependency alias {alias!r}: destination resolves to apm_modules itself. " + "Choose a separate package directory and rerun apm install." + ) + return result + if dependency.is_local and dependency.local_path: pkg_dir_name = Path(dependency.local_path).name validate_path_segments( @@ -328,7 +347,7 @@ def prepare_materialization_path( ) -> Path: """Return the display-cased path, transactionally migrating stale casing.""" desired = dependency.get_install_path(apm_modules_dir) - if not dependency.has_case_insensitive_repo_identity: + if dependency.alias is not None or not dependency.has_case_insensitive_repo_identity: return desired existing = find_case_equivalent_materialization_path( diff --git a/src/apm_cli/models/dependency/object_fields.py b/src/apm_cli/models/dependency/object_fields.py index 0efa662058..fb1816341f 100644 --- a/src/apm_cli/models/dependency/object_fields.py +++ b/src/apm_cli/models/dependency/object_fields.py @@ -6,6 +6,7 @@ from collections.abc import Collection from typing import Any +from ...utils.path_security import PathTraversalError, validate_path_segments from .subsets import parse_skill_subset, parse_target_subset _ALIAS_PATTERN = re.compile(r"^[a-zA-Z0-9._-]+$") @@ -36,6 +37,13 @@ def parse_alias_override(raw: object) -> str | None: f"Invalid alias: {alias}. Aliases can only contain " "letters, numbers, dots, underscores, and hyphens" ) + try: + validate_path_segments(alias, context="dependency alias") + except PathTraversalError: + raise ValueError( + f"Invalid dependency alias {alias!r}: '.' and '..' are reserved directory names. " + "Set alias to a name such as 'my-skill.v2' in apm.yml and rerun apm install." + ) from None return alias diff --git a/src/apm_cli/models/dependency/reference.py b/src/apm_cli/models/dependency/reference.py index d4a8daf4ab..5042854529 100644 --- a/src/apm_cli/models/dependency/reference.py +++ b/src/apm_cli/models/dependency/reference.py @@ -1881,10 +1881,7 @@ def parse( .startswith(("https://", "http://")), ) - if alias and not re.match(r"^[a-zA-Z0-9._-]+$", alias): - raise ValueError( - f"Invalid alias: {alias}. Aliases can only contain letters, numbers, dots, underscores, and hyphens" - ) + alias = parse_alias_override(alias) # Extract Artifactory prefix from the original path if applicable is_ado_final = host and is_azure_devops_hostname(host) diff --git a/src/apm_cli/models/dependency/registry_entry.py b/src/apm_cli/models/dependency/registry_entry.py index bc411aeb26..e9088040b9 100644 --- a/src/apm_cli/models/dependency/registry_entry.py +++ b/src/apm_cli/models/dependency/registry_entry.py @@ -13,9 +13,9 @@ class as a parameter (rather than importing it) so this module has no from ...utils.github_host import default_host from ...utils.path_security import validate_path_segments +from .object_fields import parse_alias_override from .subsets import parse_skill_subset, parse_target_subset -_ALIAS_PATTERN = re.compile(r"^[a-zA-Z0-9._-]+$") _ID_SEGMENT_PATTERN = re.compile(r"^[a-zA-Z0-9._-]+$") @@ -73,16 +73,7 @@ def parse_registry_object_entry(dependency_reference_cls: Any, entry: dict) -> A raise ValueError("Object-form registry entry: 'version' is required") version = version.strip() - alias = entry.get("alias") - if alias is not None: - if not isinstance(alias, str) or not alias.strip(): - raise ValueError("'alias' field must be a non-empty string") - alias = alias.strip() - if not _ALIAS_PATTERN.match(alias): - raise ValueError( - f"Invalid alias: {alias}. Aliases can only contain " - f"letters, numbers, dots, underscores, and hyphens" - ) + alias = parse_alias_override(entry.get("alias")) skills_raw = entry.get("skills") skill_subset = parse_skill_subset(skills_raw) if skills_raw is not None else None diff --git a/src/apm_cli/models/manifest_contract.py b/src/apm_cli/models/manifest_contract.py index 321896938e..f5821e4095 100644 --- a/src/apm_cli/models/manifest_contract.py +++ b/src/apm_cli/models/manifest_contract.py @@ -5,6 +5,9 @@ from enum import Enum OPENAPM_V01_SCHEMA_URI = "https://microsoft.github.io/apm/specs/schemas/manifest-v0.1.schema.json" +OPENAPM_V0141_SCHEMA_URI = ( + "https://microsoft.github.io/apm/specs/schemas/manifest-v0.1.41.schema.json" +) class ManifestContract(str, Enum): @@ -23,9 +26,10 @@ def negotiate_manifest_contract(data: dict) -> ManifestContract: schema_uri = data.get("$schema") if schema_uri is None: return ManifestContract.WORKING_DRAFT - if schema_uri == OPENAPM_V01_SCHEMA_URI: + if schema_uri in (OPENAPM_V01_SCHEMA_URI, OPENAPM_V0141_SCHEMA_URI): return ManifestContract.OPENAPM_V01 raise UnsupportedManifestContractError( f"Unsupported apm.yml $schema contract: {schema_uri!r}. " - f"Supported explicit contract: {OPENAPM_V01_SCHEMA_URI}" + f"Use a supported explicit contract: {OPENAPM_V01_SCHEMA_URI} " + f"or {OPENAPM_V0141_SCHEMA_URI}" ) diff --git a/tests/integration/test_architecture_contract_guards.py b/tests/integration/test_architecture_contract_guards.py index 9853fd1f24..64274adf2f 100644 --- a/tests/integration/test_architecture_contract_guards.py +++ b/tests/integration/test_architecture_contract_guards.py @@ -210,12 +210,12 @@ def test_neutral_hook_walker_preserves_tolerant_integrator_inspection() -> None: ) +@pytest.mark.parametrize("schema_revision", ["0.1", "0.1.41"]) def test_manifest_schema_negotiates_normative_v01_registry_shape( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, schema_revision: str ) -> None: """Explicit v0.1 identity must select its normative registry parser.""" from apm_cli.models.apm_package import APMPackage - from apm_cli.models.manifest_contract import OPENAPM_V01_SCHEMA_URI monkeypatch.setattr( "apm_cli.deps.registry.feature_gate.require_package_registry_enabled", @@ -225,7 +225,7 @@ def test_manifest_schema_negotiates_normative_v01_registry_shape( manifest.write_text( "\n".join( ( - f"$schema: {OPENAPM_V01_SCHEMA_URI}", + f"$schema: https://microsoft.github.io/apm/specs/schemas/manifest-v{schema_revision}.schema.json", "name: demo", "version: 1.0.0", "registries:", diff --git a/tests/integration/test_architecture_dependency_reference.py b/tests/integration/test_architecture_dependency_reference.py index e472905931..1632355c44 100644 --- a/tests/integration/test_architecture_dependency_reference.py +++ b/tests/integration/test_architecture_dependency_reference.py @@ -44,3 +44,83 @@ def test_embedded_git_url_subpath_guard_rejects_provider_bypass() -> None: violation.rule_id == RULE_ID and "Embedded git URL subpath validation" in violation.message for violation in report.violations ) + + +def test_alias_consumers_share_validation_and_materialization() -> None: + """All ingress and install boundaries route through the existing owners.""" + report = run_selected_rules(ROOT, (RULE_ID,)) + assert not report.violations + + +@pytest.mark.parametrize( + ("path", "before", "after"), + [ + ("src/apm_cli/deps/lockfile.py", "alias=self.alias", "alias=None"), + ("src/apm_cli/deps/lockfile.py", 'result["alias"] = self.alias', "pass"), + ( + "src/apm_cli/deps/lockfile.py", + "self.alias = parse_alias_override(self.alias)", + "pass", + ), + (REFERENCE, "alias = parse_alias_override(alias)", "alias = alias"), + ( + "src/apm_cli/models/dependency/registry_entry.py", + 'alias = parse_alias_override(entry.get("alias"))', + 'alias = entry.get("alias")', + ), + ( + "src/apm_cli/models/dependency/object_fields.py", + 'validate_path_segments(alias, context="dependency alias")', + "pass", + ), + ( + "src/apm_cli/models/dependency/materialization.py", + "if resolved == ensure_path_within(apm_modules_dir, apm_modules_dir):", + "if False:", + ), + ( + "src/apm_cli/install/phases/download.py", + "_pd_path = _pd_ref.get_install_path(apm_modules_dir)", + "_pd_path = apm_modules_dir / _pd_ref.alias", + ), + ( + "src/apm_cli/install/phases/integrate.py", + "install_path = dep_ref.get_install_path(apm_modules_dir)", + "install_path = apm_modules_dir / dep_ref.alias", + ), + ( + "src/apm_cli/install/phases/resolve.py", + "cache_validation_callback=partial(", + "bypassed_cache_validation_callback=partial(", + ), + ( + "src/apm_cli/deps/apm_resolver.py", + "if parent_dep.alias:", + "if False:", + ), + ( + "src/apm_cli/deps/apm_resolver.py", + "self._cache_validation_callback(install_path, dep_ref.get_unique_key())", + "pass", + ), + ( + "src/apm_cli/deps/apm_resolver.py", + "had_existing_install = install_path.exists()", + "had_existing_install = install_path.exists()\n" + " self._cache_validation_callback(install_path, dep_ref.get_unique_key())", + ), + ], +) +def test_alias_owner_guard_rejects_bypass(path: str, before: str, after: str) -> None: + """Restoring a split alias decision must trip the registered static guard.""" + source = (ROOT / path).read_text(encoding="utf-8") + assert before in source + report = run_selected_rules( + ROOT, + (RULE_ID,), + source_overrides={path: source.replace(before, after, 1)}, + ) + assert any( + violation.rule_id == RULE_ID and "Dependency aliases" in violation.message + for violation in report.violations + ) diff --git a/tests/integration/test_architecture_owner_rule_mutations.py b/tests/integration/test_architecture_owner_rule_mutations.py index 036f70b41d..1dfd957181 100644 --- a/tests/integration/test_architecture_owner_rule_mutations.py +++ b/tests/integration/test_architecture_owner_rule_mutations.py @@ -1153,6 +1153,25 @@ def test_owner_rules_report_nothing_before_mutation( assert baseline_violated_rule_ids == frozenset() +def test_ref_freshness_guard_rejects_unconditional_cache_publication() -> None: + """A checkout must not promote a lock pin into a fresh named observation.""" + path = "src/apm_cli/deps/github_downloader.py" + source = _source(path) + old = "resolver.remotely_resolved(dep_ref, locked_sha) is True" + assert source.count(old) == 1 + mutated = source.replace(old, "True", 1) + ast.parse(mutated, filename=path) + report = run_selected_rules( + ROOT, + ("transport-platform-ref-freshness",), + source_overrides={path: mutated}, + ) + assert report.failures == () + assert any( + violation.rule_id == "transport-platform-ref-freshness" for violation in report.violations + ) + + def test_git_semver_guard_rejects_bypassing_selected_attempt_requested_url() -> None: """AC13 must retain the selected transport attempt as the requested-URL owner.""" path = "src/apm_cli/install/helpers/ref_reuse.py" diff --git a/tests/integration/test_hook_js_sidecar_lifecycle_contract.py b/tests/integration/test_hook_js_sidecar_lifecycle_contract.py index 9eba2e7516..662cea4875 100644 --- a/tests/integration/test_hook_js_sidecar_lifecycle_contract.py +++ b/tests/integration/test_hook_js_sidecar_lifecycle_contract.py @@ -10,6 +10,7 @@ import pytest +from apm_cli.cache.git_cache import GitCache from apm_cli.core.deployment_ledger import DeploymentLedgerCodec from apm_cli.deps.lockfile import LockFile from apm_cli.integration.base_integrator import BaseIntegrator @@ -418,6 +419,7 @@ def test_required_copilot_and_vscode_js_hook_lifecycle( ) assert consumer.manifest_path.read_bytes() == manifest_bytes _assert_copilot_contract(consumer, expected_marker="esm-hook-v1") + initial_lock = (consumer.root / "apm.lock.yaml").read_bytes() _execute_hook( scenario, consumer, @@ -453,6 +455,21 @@ def test_required_copilot_and_vscode_js_hook_lifecycle( ) after_update = _capture_copilot(consumer) + if target_selector == "copilot": + receipt = GitCache(scenario.isolated.cache_root)._resolved_ref_path(_REMOTE_URL, "main") + current_receipt = receipt.read_bytes() + old_consumer = _consumer(scenario, published, name="older-lock-consumer") + (old_consumer.root / "apm.lock.yaml").write_bytes(initial_lock) + _run_success( + scenario, + old_consumer, + install_args, + environment=published.environment, + scenario_id="copilot-replay-older-lock", + ) + _assert_copilot_contract(old_consumer, expected_marker="esm-hook-v1") + assert receipt.read_bytes() == current_receipt + _run_success( scenario, consumer, diff --git a/tests/integration/test_required_lifecycle_state_machine.py b/tests/integration/test_required_lifecycle_state_machine.py index 90f13bcf97..385305f186 100644 --- a/tests/integration/test_required_lifecycle_state_machine.py +++ b/tests/integration/test_required_lifecycle_state_machine.py @@ -1363,9 +1363,11 @@ def test_required_lock_preserves_user_edited_dropped_file_and_row( assert _record_by_value(locked, dropped_skill).owners == dropped_record.owners +@pytest.mark.parametrize("alias", [".safe", "safe.", "foo..bar", "my-skill.v2"]) def test_required_reinstall_is_byte_idempotent_across_durable_state( tmp_path: Path, apm_binary_path: Path, + alias: str, ) -> None: scenario = _new_scenario(tmp_path / "reinstall-idempotency", apm_binary_path) source = _publish( @@ -1376,7 +1378,8 @@ def test_required_reinstall_is_byte_idempotent_across_durable_state( ) consumer = scenario.consumers.create( "stable-consumer", - dependencies=(source.dependency,), + version="9.0.0", + dependencies=({**source.dependency, "alias": alias},), targets=("copilot",), ) @@ -1424,6 +1427,31 @@ def test_required_reinstall_is_byte_idempotent_across_durable_state( assert_unchanged(before_artifacts, after_artifacts) assert after.file(".github/instructions/stable.instructions.md").kind == "file" assert audit["passed"] is True + locked = LockFile.read(consumer.root / "apm.lock.yaml") + assert locked is not None + assert len(locked.dependencies) == 1 + dependency = next(iter(locked.dependencies.values())) + installed = consumer.root / "apm_modules" / alias + assert dependency.name == source.package.name + assert dependency.version == load_yaml(source.package.manifest_path)["version"] + assert dependency.name != consumer.name + assert dependency.content_hash == compute_package_hash(installed) + assert not (consumer.root / "apm_modules" / _OWNER).exists() + + for rejected in (".", ".."): + manifest = load_yaml(consumer.manifest_path) + manifest["dependencies"]["apm"][0]["alias"] = rejected + dump_yaml(manifest, consumer.manifest_path) + before_rejection = ArtifactSnapshot.capture(consumer.root) + result = scenario.runner.run( + _INSTALL_ARGS, + cwd=consumer.root, + env=source.environment, + scenario_id=f"reinstall-reject-alias-{len(rejected)}", + ) + assert result.returncode != 0, _result_evidence(result) + assert "reserved directory names" in result.stdout + result.stderr + assert_unchanged(before_rejection, ArtifactSnapshot.capture(consumer.root)) def test_required_legacy_content_hash_upgrade_preserves_skills_and_converges( @@ -1605,24 +1633,32 @@ def test_required_parallel_fresh_fetch_bypasses_legacy_cache_upgrade( @pytest.mark.parametrize( - "invalid_cache", - ( - "plugin-path", - "missing-hash", - "missing-apm-yml", - "missing-apm-dir", - "apm-yml-symlink", - "apm-dir-symlink", - "package-root-symlink", - ), + ("invalid_cache", "replace_ref"), + [ + *( + pytest.param(invalid_cache, False, id=invalid_cache) + for invalid_cache in ( + "plugin-path", + "missing-hash", + "missing-apm-yml", + "missing-apm-dir", + "apm-yml-symlink", + "apm-dir-symlink", + "package-root-symlink", + ) + ), + pytest.param("missing-apm-yml", True, id="replacement-missing-apm-yml"), + pytest.param("missing-apm-dir", True, id="replacement-missing-apm-dir"), + ], ) @pytest.mark.lifecycle_merge_group def test_required_invalid_receiptless_legacy_cache_fails_with_recovery( tmp_path: Path, apm_binary_path: Path, invalid_cache: str, + replace_ref: bool, ) -> None: - """Reject invalid 0.28 cache state without mutating cache or deployments.""" + """Reject invalid reused caches, but permit a requested replacement fetch.""" scenario = _new_scenario( tmp_path / f"invalid-legacy-cache-{invalid_cache}", apm_binary_path, @@ -1705,6 +1741,10 @@ def test_required_invalid_receiptless_legacy_cache_fails_with_recovery( else: locked_dependency["content_hash"] = compute_package_hash(cached_package) dump_yaml(lock_document, lock_path) + if replace_ref: + manifest = load_yaml(consumer.manifest_path) + manifest["dependencies"]["apm"][0]["ref"] = "HEAD" + dump_yaml(manifest, consumer.manifest_path) before_state = LifecycleStateSnapshot.capture(consumer.root, targets=("claude", "codex")) before_cache = ArtifactSnapshot.capture(cached_package) @@ -1721,11 +1761,27 @@ def test_required_invalid_receiptless_legacy_cache_fails_with_recovery( ) output = " ".join((result.stdout + result.stderr).split()) + if replace_ref: + assert result.returncode == 0, _result_evidence(result) + assert receipt.is_file() + _, replaced = _single_locked_dependency(consumer.root) + assert replaced.resolved_commit == source.commit.sha + assert replaced.content_hash == compute_package_hash(cached_package) + assert (cached_package / "apm.yml").is_file() + for target in (".claude", ".agents"): + deployed = consumer.root / target / "skills" / "legacy-skill" / "SKILL.md" + assert deployed.read_text(encoding="utf-8") == _skill("legacy-skill") + return + assert result.returncode != 0, _result_evidence(result) assert source.package.name in output assert str(cached_package) in "".join(output.split()) - assert "apm deps clean --yes" in output - if invalid_cache == "plugin-path": + if invalid_cache == "package-root-symlink": + assert "unsafe destination" in output + assert "without removing its target" in output + assert "rerun apm install" in output + assert "apm deps clean --yes" not in output + elif invalid_cache == "plugin-path": assert "is invalid" in output elif invalid_cache == "missing-hash": assert "no content hash" in output @@ -1735,6 +1791,8 @@ def test_required_invalid_receiptless_legacy_cache_fails_with_recovery( assert "required .apm directory is missing" in output else: assert "cache metadata contains a symlink" in output + if invalid_cache != "package-root-symlink": + assert "apm deps clean --yes" in output if package_link_target is not None: assert cached_package.is_symlink() @@ -1807,8 +1865,8 @@ def test_required_dependency_prune_then_uninstall_cascades_owned_state( ) after_prune = LifecycleStateSnapshot.capture(consumer.root, targets=("claude",)) - assert not (consumer.root / "apm_modules" / _OWNER / "beta-kit").exists() - assert (consumer.root / "apm_modules" / _OWNER / "alpha-kit").is_dir() + assert not (consumer.root / "apm_modules" / "beta-kit").exists() + assert (consumer.root / "apm_modules" / "alpha-kit").is_dir() assert "echo beta" not in _hook_commands(settings) assert _hook_commands(settings) == ["echo alpha"] assert not any( diff --git a/tests/integration/test_transitive_chain_e2e.py b/tests/integration/test_transitive_chain_e2e.py index 4b84770c70..3c6607530e 100644 --- a/tests/integration/test_transitive_chain_e2e.py +++ b/tests/integration/test_transitive_chain_e2e.py @@ -336,7 +336,8 @@ def test_three_level_chain_uninstall_root_cascades(chain_workspace, apm_binary_p assert not (deployed / fname).exists(), f"Primitive {fname} survived cascade uninstall" -def test_asymmetric_layout_anchors_on_declaring_pkg(tmp_path, apm_binary_path): +@pytest.mark.parametrize("aliased", [False, True]) +def test_asymmetric_layout_anchors_on_declaring_pkg(tmp_path, apm_binary_path, aliased): """Regression for #857: a transitive ../sibling resolves against the DECLARING package's directory, not the consumer's project root. @@ -356,8 +357,16 @@ def test_asymmetric_layout_anchors_on_declaring_pkg(tmp_path, apm_binary_path): pkgs = consumer / "packages" pkgs.mkdir() - _write_pkg(pkgs / "base", "base-pkg", [], "base-skill") - _write_pkg(pkgs / "specialized", "specialized-pkg", ["../base"], "specialized-skill") + _write_pkg(pkgs / "leaf", "leaf-pkg", [], "leaf-skill") + leaf_ref = {"path": "../leaf", "alias": "foo..bar"} if aliased else "../leaf" + base_ref = {"path": "../base", "alias": ".safe"} if aliased else "../base" + root_ref = ( + {"path": "./packages/specialized", "alias": "my-skill.v2"} + if aliased + else "./packages/specialized" + ) + _write_pkg(pkgs / "base", "base-pkg", [leaf_ref], "base-skill") + _write_pkg(pkgs / "specialized", "specialized-pkg", [base_ref], "specialized-skill") (consumer / "apm.yml").write_text( yaml.dump( @@ -365,7 +374,7 @@ def test_asymmetric_layout_anchors_on_declaring_pkg(tmp_path, apm_binary_path): "name": "consumer", "version": "1.0.0", "target": "copilot", - "dependencies": {"apm": ["./packages/specialized"]}, + "dependencies": {"apm": [root_ref]}, } ) ) @@ -383,8 +392,16 @@ def test_asymmetric_layout_anchors_on_declaring_pkg(tmp_path, apm_binary_path): # Both packages must be materialized — the transitive ../base proves the # anchor is on specialized/, not on consumer/. Install path uses the # source-dir basename (NOT the apm.yml `name` field). - assert (consumer / "apm_modules" / "_local" / "specialized").exists() - assert len(list((consumer / "apm_modules" / "_local").glob("*/base"))) == 1 + modules = consumer / "apm_modules" + if aliased: + for alias in ("my-skill.v2", ".safe", "foo..bar"): + assert (modules / alias / "apm.yml").is_file() + else: + assert (modules / "_local" / "specialized").exists() + for name in ("base", "leaf"): + assert len(list((modules / "_local").glob(f"*/{name}"))) == 1 + for name in ("specialized", "base", "leaf"): + assert (consumer / ".github" / "instructions" / f"{name}-skill.instructions.md").is_file() # No "outside the project root" rejection should appear in either stream. combined = result.stdout + result.stderr assert "outside the project root" not in combined, combined diff --git a/tests/red_team/install/test_alias_path_escape.py b/tests/red_team/install/test_alias_path_escape.py new file mode 100644 index 0000000000..5bb6200a05 --- /dev/null +++ b/tests/red_team/install/test_alias_path_escape.py @@ -0,0 +1,205 @@ +"""Both install phases must consult the strict alias materialization owner. + +Real constructed references bypass ingress validation. Each phase independently +rejects reserved names and symlinks to the modules root or outside it, before +download or integration. Remote transport and integration outputs are mocked; +the required lifecycle suite verifies real installed metadata and hashes. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from apm_cli.install.context import InstallContext +from apm_cli.models.dependency import DependencyReference +from apm_cli.utils.path_security import PathTraversalError + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_download_ctx(tmp_path: Path) -> InstallContext: + project_root = tmp_path / "project" + project_root.mkdir() + apm_dir = project_root / ".apm" + apm_dir.mkdir() + modules = project_root / "apm_modules" + modules.mkdir() + + ctx = InstallContext(project_root=project_root, apm_dir=apm_dir) + ctx.apm_modules_dir = modules + ctx.deps_to_install = [] + ctx.callback_downloaded = frozenset() + ctx.callback_failures = set() + ctx.existing_lockfile = None + ctx.update_refs = False + ctx.parallel_downloads = 4 + ctx.downloader = MagicMock() + ctx.pre_download_results = {} + ctx.content_hash_verified_deps = set() + return ctx + + +def _make_integrate_ctx(tmp_path: Path) -> InstallContext: + project_root = tmp_path / "project" + project_root.mkdir() + apm_dir = project_root / ".apm" + apm_dir.mkdir() + modules = project_root / "apm_modules" + modules.mkdir() + + ctx = InstallContext(project_root=project_root, apm_dir=apm_dir) + ctx.apm_modules_dir = modules + ctx.deps_to_install = [] + ctx.all_apm_deps = [] + ctx.callback_failures = set() + ctx.callback_downloaded = frozenset() + ctx.pre_downloaded_keys = set() + ctx.existing_lockfile = None + ctx.diagnostics = MagicMock() + ctx.tui = None + ctx.root_has_local_primitives = False + ctx.installed_count = 0 + ctx.unpinned_count = 0 + ctx.installed_packages = [] + ctx.package_deployed_files = {} + ctx.package_types = {} + ctx.package_hashes = {} + ctx.total_prompts_integrated = 0 + ctx.total_agents_integrated = 0 + ctx.total_skills_integrated = 0 + ctx.total_sub_skills_promoted = 0 + ctx.total_instructions_integrated = 0 + ctx.total_commands_integrated = 0 + ctx.total_hooks_integrated = 0 + ctx.total_links_resolved = 0 + ctx.managed_files = set() + ctx.old_local_deployed = [] + return ctx + + +def _make_dep_ref(key: str, *, alias: str, is_local: bool = True) -> DependencyReference: + """Construct a real reference that bypasses ingress validation.""" + return DependencyReference( + repo_url=key, + alias=alias, + is_local=is_local, + local_path="./local" if is_local else None, + ) + + +# --------------------------------------------------------------------------- +# Download phase guard (download.py:65) +# --------------------------------------------------------------------------- + + +class TestDownloadRejectsEscapingAlias: + @pytest.mark.parametrize("alias", [".", "..", "../../etc"]) + def test_dotdot_alias_rejected(self, tmp_path: Path, alias: str) -> None: + from apm_cli.install.phases.download import run + + ctx = _make_download_ctx(tmp_path) + ctx.deps_to_install = [_make_dep_ref("org/pkg", alias=alias)] + + with pytest.raises(ValueError): + run(ctx) + + ctx.downloader.download_package.assert_not_called() + + @pytest.mark.parametrize("target", ["outside", "root"]) + def test_symlink_escape_alias_rejected(self, tmp_path: Path, target: str) -> None: + from apm_cli.install.phases.download import run + + ctx = _make_download_ctx(tmp_path) + outside = tmp_path / "outside_target" + outside.mkdir() + link = ctx.apm_modules_dir / "evil_link" + link.symlink_to(outside if target == "outside" else ctx.apm_modules_dir) + ctx.deps_to_install = [_make_dep_ref("org/pkg", alias="evil_link")] + + with pytest.raises(PathTraversalError): + run(ctx) + + ctx.downloader.download_package.assert_not_called() + + +# --------------------------------------------------------------------------- +# Integrate phase guard (integrate.py:622) +# --------------------------------------------------------------------------- + + +class TestIntegrateRejectsEscapingAlias: + @pytest.mark.parametrize("alias", [".", "..", "../../etc"]) + def test_dotdot_alias_rejected(self, tmp_path: Path, alias: str) -> None: + from apm_cli.install.phases.integrate import run + + ctx = _make_integrate_ctx(tmp_path) + ctx.deps_to_install = [_make_dep_ref("org/pkg", alias=alias)] + + with pytest.raises(ValueError): + run(ctx) + + @pytest.mark.parametrize("target", ["outside", "root"]) + def test_symlink_escape_alias_rejected(self, tmp_path: Path, target: str) -> None: + from apm_cli.install.phases.integrate import run + + ctx = _make_integrate_ctx(tmp_path) + outside = tmp_path / "outside_target" + outside.mkdir() + link = ctx.apm_modules_dir / "evil_link" + link.symlink_to(outside if target == "outside" else ctx.apm_modules_dir) + ctx.deps_to_install = [_make_dep_ref("org/pkg", alias="evil_link")] + + with pytest.raises(PathTraversalError): + run(ctx) + + +# --------------------------------------------------------------------------- +# Control: a safe alias must pass through both guards (no false positive) +# --------------------------------------------------------------------------- + + +class TestSafeAliasPasses: + def test_safe_alias_passes_download(self, tmp_path: Path) -> None: + from apm_cli.install.phases.download import run + + ctx = _make_download_ctx(tmp_path) + ctx.deps_to_install = [_make_dep_ref("org/pkg", alias="safe-name")] + + run(ctx) + + expected = ctx.apm_modules_dir / "safe-name" + assert expected.resolve().is_relative_to(ctx.apm_modules_dir.resolve()) + + @patch("apm_cli.install.phases.integrate._integrate_root_project", return_value=None) + @patch("apm_cli.install.phases.integrate.run_integration_template") + @patch("apm_cli.install.phases.integrate.make_dependency_source") + def test_safe_alias_passes_integrate( + self, mock_source, mock_template, _mock_root, tmp_path: Path + ) -> None: + from apm_cli.install.phases.integrate import run + + mock_source.return_value = MagicMock() + mock_template.return_value = { + "installed": 1, + "unpinned": 0, + "prompts": 0, + "agents": 0, + "skills": 0, + "sub_skills": 0, + "instructions": 0, + "commands": 0, + "hooks": 0, + "links_resolved": 0, + } + + ctx = _make_integrate_ctx(tmp_path) + ctx.deps_to_install = [_make_dep_ref("org/pkg", alias="safe-name")] + + run(ctx) + + assert ctx.installed_count == 1 diff --git a/tests/spec_conformance/test_alias_reqs.py b/tests/spec_conformance/test_alias_reqs.py new file mode 100644 index 0000000000..b114049297 --- /dev/null +++ b/tests/spec_conformance/test_alias_reqs.py @@ -0,0 +1,193 @@ +"""Dependency alias validation, containment, and durable replay conformance.""" + +from pathlib import Path +from urllib.parse import urlparse + +import jsonschema +import pytest + +from apm_cli.deps.lockfile import LockedDependency, LockFile +from apm_cli.deps.registry.resolver import RegistryResolution +from apm_cli.models.apm_package import DependencyReference +from apm_cli.utils.path_security import PathTraversalError +from tests.spec_conformance._helpers import ( + load_schema, + load_yaml_fixture, + sha256_hex, + validate_against, +) + +pytestmark = pytest.mark.component + + +@pytest.mark.parametrize( + ("name", "digest"), + [ + ("manifest", "7bdefbe443d3315d71add021c777d776c9cfd4942acb19750a799f46fa0d1344"), + ("lockfile", "6c0dca9e7994035b55da17340b1f9f6c6673501c63ebd947400cf472d5723560"), + ], +) +def test_published_schema_content_remains_immutable( + repo_root: Path, name: str, digest: str +) -> None: + """Preserve published LF content regardless of local Git checkout newlines.""" + path = repo_root / "docs" / "public" / "specs" / "schemas" / f"{name}-v0.1.schema.json" + assert sha256_hex(path.read_text(encoding="utf-8").encode("utf-8")) == digest + + +@pytest.mark.parametrize("name", ["manifest", "lockfile"]) +def test_alias_schema_revision_has_distinct_published_identity(name: str) -> None: + """The alias-aware schema cannot overwrite a cached validator at the old ID.""" + old = load_schema(f"{name}-v0.1.schema.json") + revised = load_schema(f"{name}-v0.1.41.schema.json") + jsonschema.Draft202012Validator.check_schema(revised) + old_id = urlparse(old["$id"]) + new_id = urlparse(revised["$id"]) + assert old_id.path == f"/apm/specs/schemas/{name}-v0.1.schema.json" + assert new_id.path == f"/apm/specs/schemas/{name}-v0.1.41.schema.json" + assert new_id.scheme == old_id.scheme == "https" + assert new_id.hostname == old_id.hostname == "microsoft.github.io" + assert new_id.query == new_id.fragment == "" + + +@pytest.mark.req("req-mf-025") +@pytest.mark.parametrize( + "alias", [None, ".safe", "safe.", "foo..bar", "my-skill.v2", " \tMy.Safe\n"] +) +@pytest.mark.parametrize("source", ["git", "local", "registry"]) +def test_alias_survives_lock_replay_without_changing_source( + tmp_path: Path, alias: str | None, source: str +) -> None: + """Lock serialization preserves placement independently of source identity.""" + dependency = DependencyReference( + repo_url="owner/package", + host="github.com", + reference="v1", + alias=alias, + source=source, + is_local=source == "local", + local_path="../sibling" if source == "local" else None, + declaring_parent="owner/parent" if source == "local" else None, + anchored_local_path=str(tmp_path / "sibling") if source == "local" else None, + ) + resolution = ( + RegistryResolution( + resolved_url="https://registry.example.com/package.tgz", + resolved_hash="sha256:" + "b" * 64, + version="1.0.0", + ) + if source == "registry" + else None + ) + locked = LockedDependency.from_dependency_ref( + dependency, + "a" * 40, + depth=2, + resolved_by="owner/parent", + registry_resolution=resolution, + ) + lockfile = LockFile() + lockfile.add_dependency(locked) + restored_lock = LockFile.from_yaml(lockfile.to_yaml()) + restored = restored_lock.get_dependency(locked.get_unique_key()) + assert restored is not None + replay = restored.to_dependency_ref() + assert replay.alias == (alias.strip() if alias is not None else None) + assert replay.get_install_path(tmp_path / "apm_modules") == dependency.get_install_path( + tmp_path / "apm_modules" + ) + assert replay.get_unique_key() == dependency.get_unique_key() + assert replay.local_path == dependency.local_path + assert replay.anchored_local_path == dependency.anchored_local_path + assert replay.declaring_parent == dependency.declaring_parent + assert restored.resolved_commit == "a" * 40 + assert restored.to_dict().get("alias") == replay.alias + assert "alias" not in restored._unknown_fields + if resolution is not None: + assert restored.source == "registry" + assert restored.resolved_url == resolution.resolved_url + assert restored.resolved_hash == resolution.resolved_hash + + +@pytest.mark.req("req-mf-025") +def test_absent_alias_is_not_inferred_from_inventory_name() -> None: + """An old lock's display name does not become placement metadata.""" + locked = LockedDependency.from_dict( + {"repo_url": "owner/package", "name": "different-inventory-name"} + ) + assert locked.to_dependency_ref().alias is None + assert "alias" not in locked.to_dict() + + +@pytest.mark.req("req-mf-025") +def test_invalid_lock_alias_guidance_repairs_the_lock_not_the_manifest() -> None: + """A lock-only defect must not send users back to an already-valid manifest.""" + with pytest.raises(ValueError, match=r"Restore a known-good apm\.lock\.yaml") as caught: + LockedDependency.from_dict({"repo_url": "owner/package", "alias": ".."}) + assert "Do not delete content" in str(caught.value) + assert "in apm.yml" not in str(caught.value) + + +@pytest.mark.req("req-mf-025") +@pytest.mark.parametrize( + ("alias", "manifest_valid", "lock_valid"), + [ + (".safe", True, True), + ("safe.", True, True), + ("foo..bar", True, True), + ("my-skill.v2", True, True), + (" \tMy.Safe\n", True, False), + ("safe\n", True, False), + (".", False, False), + ("..", False, False), + ("..\n", False, False), + (" \r..\t", False, False), + ("safe\ninside", False, False), + ("", False, False), + (" \t\n", False, False), + ("../escape", False, False), + ("safe/name", False, False), + (1, False, False), + ], +) +def test_alias_schemas_distinguish_input_from_canonical_output( + alias: object, manifest_valid: bool, lock_valid: bool +) -> None: + """Whole schemas enforce canonical lock output without banning local sources.""" + manifest = { + "name": "consumer", + "version": "1.0.0", + "dependencies": {"apm": [{"path": "../sibling", "alias": alias}]}, + } + lock = load_yaml_fixture("lockfile", "v2-with-registry.yml") + lock["dependencies"][0]["alias"] = alias + for schema, document, valid in ( + ("manifest-v0.1.41.schema.json", manifest, manifest_valid), + ("lockfile-v0.1.41.schema.json", lock, lock_valid), + ): + if valid: + validate_against(schema, document) + else: + with pytest.raises(jsonschema.ValidationError): + validate_against(schema, document) + + +@pytest.mark.req("req-mf-025") +@pytest.mark.parametrize("alias", [".", "..", "../escape", "foo/../bar", "%2e%2e", "", 1]) +def test_lock_alias_rejects_invalid_destination_names(alias: object) -> None: + """A tampered lock cannot bypass the manifest alias validator.""" + with pytest.raises(ValueError, match="alias"): + LockedDependency.from_dict({"repo_url": "owner/package", "alias": alias}) + + +@pytest.mark.req("req-mf-025") +@pytest.mark.parametrize("target", ["root", "outside"]) +def test_alias_rejects_symlink_destination(tmp_path: Path, target: str) -> None: + """Destination containment also applies when the lexical alias is valid.""" + modules = tmp_path / "apm_modules" + modules.mkdir() + destination = modules if target == "root" else tmp_path + (modules / "safe").symlink_to(destination, target_is_directory=True) + dependency = DependencyReference(repo_url="owner/package", alias="safe") + with pytest.raises(PathTraversalError, match="alias"): + dependency.get_install_path(modules) diff --git a/tests/spec_conformance/test_lockfile_reqs.py b/tests/spec_conformance/test_lockfile_reqs.py index b1d7b08273..628fbae9ab 100644 --- a/tests/spec_conformance/test_lockfile_reqs.py +++ b/tests/spec_conformance/test_lockfile_reqs.py @@ -39,19 +39,19 @@ @pytest.mark.req("req-lk-001") def test_lockfile_valid_v2_passes_schema(): - validate_against("lockfile-v0.1.schema.json", load_yaml_fixture(*V2)) + validate_against("lockfile-v0.1.41.schema.json", load_yaml_fixture(*V2)) @pytest.mark.req("req-lk-002") def test_lockfile_declares_apiversion(): - schema = load_schema("lockfile-v0.1.schema.json") + schema = load_schema("lockfile-v0.1.41.schema.json") assert "lockfile_version" in schema["required"] assert set(schema["properties"]["lockfile_version"]["enum"]) == {"1", "2"} @pytest.mark.req("req-lk-003") def test_lockfile_carries_dependencies_block(): - schema = load_schema("lockfile-v0.1.schema.json") + schema = load_schema("lockfile-v0.1.41.schema.json") assert "dependencies" in schema["required"] @@ -84,12 +84,12 @@ def test_full_sha_pin_audit_rejects_resolved_commit_mismatch(): @pytest.mark.req("req-lk-004") def test_lockfile_v1_remains_parseable_under_v2_reader(): - validate_against("lockfile-v0.1.schema.json", load_yaml_fixture(*V1)) + validate_against("lockfile-v0.1.41.schema.json", load_yaml_fixture(*V1)) @pytest.mark.req("req-lk-005") def test_lockfile_dependency_carries_resolved_field(): - schema = load_schema("lockfile-v0.1.schema.json") + schema = load_schema("lockfile-v0.1.41.schema.json") entry_props = schema["$defs"]["entry"]["properties"] for key in ("resolved_ref", "resolved_commit", "version"): assert key in entry_props, f"entry MUST permit `{key}`" @@ -176,7 +176,7 @@ def test_frozen_mcp_validation_fails_before_durable_mutation(tmp_path): @pytest.mark.req("req-lk-007") def test_lockfile_should_record_resolution_metadata(): - schema = load_schema("lockfile-v0.1.schema.json") + schema = load_schema("lockfile-v0.1.41.schema.json") props = schema["properties"] for key in ("generated_at", "apm_version"): assert key in props @@ -185,7 +185,7 @@ def test_lockfile_should_record_resolution_metadata(): @pytest.mark.req("req-lk-008") def test_lockfile_supports_registry_source(): - schema = load_schema("lockfile-v0.1.schema.json") + schema = load_schema("lockfile-v0.1.41.schema.json") entry = schema["$defs"]["entry"]["properties"] assert "registry_prefix" in entry and "host" in entry @@ -209,7 +209,7 @@ def test_lockfile_records_registry_digest(): def test_lockfile_round_trips_unknown_fields(): doc = load_yaml_fixture(*RT) assert doc is not None - schema = load_schema("lockfile-v0.1.schema.json") + schema = load_schema("lockfile-v0.1.41.schema.json") assert schema["additionalProperties"] is True assert "^x-[a-z][a-z0-9-]*$" in schema["patternProperties"] @@ -220,7 +220,7 @@ def test_lockfile_round_trips_unknown_fields(): @pytest.mark.req("req-lk-012") def test_lockfile_canonical_tree_sha256_field_present(): """Canonical-tree hash MUST be `tree_sha256` (sec.5.6.4).""" - schema = load_schema("lockfile-v0.1.schema.json") + schema = load_schema("lockfile-v0.1.41.schema.json") entry = schema["$defs"]["entry"]["properties"] assert "tree_sha256" in entry assert entry["tree_sha256"]["$ref"] == "#/$defs/hashEnvelope" @@ -256,7 +256,7 @@ def test_lockfile_unknown_hash_algorithm_rejected(): "local_deployed_file_hashes": {"a.md": "md5:c62747a2802841aa"}, } with pytest.raises(jsonschema.ValidationError): - validate_against("lockfile-v0.1.schema.json", bad) + validate_against("lockfile-v0.1.41.schema.json", bad) @pytest.mark.req("req-lk-015") @@ -270,10 +270,10 @@ def test_lockfile_tree_sha256_canonicalisation_invariant(): @pytest.mark.req("req-lk-016") def test_lockfile_reader_tolerates_bare_hex_hash(): """v0.1 schema tolerates bare-hex; v0.2 will require envelope.""" - schema = load_schema("lockfile-v0.1.schema.json") + schema = load_schema("lockfile-v0.1.41.schema.json") pattern = schema["properties"]["local_deployed_file_hashes"]["additionalProperties"]["pattern"] assert "[0-9a-f]{64}" in pattern - validate_against("lockfile-v0.1.schema.json", load_yaml_fixture(*BARE_HEX_LOCKFILE)) + validate_against("lockfile-v0.1.41.schema.json", load_yaml_fixture(*BARE_HEX_LOCKFILE)) @pytest.mark.req("req-lk-017") @@ -307,7 +307,7 @@ def test_lockfile_deployed_file_hash_mismatch_fails_closed(): @pytest.mark.req("req-lk-018") def test_lockfile_should_record_publish_timestamp(): - schema = load_schema("lockfile-v0.1.schema.json") + schema = load_schema("lockfile-v0.1.41.schema.json") assert "generated_at" in schema["properties"] waive( "Publish-timestamp recording is a publisher-side SHOULD that " @@ -323,7 +323,7 @@ def test_lockfile_inventory_metadata_is_non_trust_anchor(): # The optional `name`/`version` inventory fields MUST be permitted # on an entry and MUST validate when carried alongside the trust # anchors -- they are additive metadata, not identity. - schema = load_schema("lockfile-v0.1.schema.json") + schema = load_schema("lockfile-v0.1.41.schema.json") entry_props = schema["$defs"]["entry"]["properties"] for key in ("name", "version"): assert key in entry_props, f"entry MUST permit `{key}`" @@ -341,7 +341,7 @@ def test_lockfile_inventory_metadata_is_non_trust_anchor(): } ], } - validate_against("lockfile-v0.1.schema.json", doc) + validate_against("lockfile-v0.1.41.schema.json", doc) # The normative boundary: package-declared fields are self-asserted, # never trust anchors, and never identity/dedup keys. Registry version @@ -359,10 +359,10 @@ def test_lockfile_inventory_metadata_is_non_trust_anchor(): def test_lockfile_materialization_spelling_is_non_identity_metadata(): from apm_cli.deps.lockfile import LockedDependency - schema = load_schema("lockfile-v0.1.schema.json") + schema = load_schema("lockfile-v0.1.41.schema.json") entry_props = schema["$defs"]["entry"]["properties"] assert entry_props["materialization_repo_url"]["type"] == "string" - validate_against("lockfile-v0.1.schema.json", load_yaml_fixture(*V1)) + validate_against("lockfile-v0.1.41.schema.json", load_yaml_fixture(*V1)) dependency = LockedDependency( repo_url="contoso/example", diff --git a/tests/spec_conformance/test_manifest_reqs.py b/tests/spec_conformance/test_manifest_reqs.py index a4c12ad879..333347e9f1 100644 --- a/tests/spec_conformance/test_manifest_reqs.py +++ b/tests/spec_conformance/test_manifest_reqs.py @@ -47,16 +47,16 @@ @pytest.mark.req("req-mf-001") def test_manifest_required_keys_enforced_by_schema(): - schema = load_schema("manifest-v0.1.schema.json") + schema = load_schema("manifest-v0.1.41.schema.json") assert set(schema["required"]) == {"name", "version"} validate_against( - "manifest-v0.1.schema.json", load_yaml_fixture("manifest", "valid-minimal.yml") + "manifest-v0.1.41.schema.json", load_yaml_fixture("manifest", "valid-minimal.yml") ) @pytest.mark.req("req-mf-002") def test_manifest_name_is_non_empty_string(): - schema = load_schema("manifest-v0.1.schema.json") + schema = load_schema("manifest-v0.1.41.schema.json") assert schema["properties"]["name"]["type"] == "string" assert schema["properties"]["name"]["minLength"] == 1 doc = load_yaml_fixture("manifest", "valid-minimal.yml") @@ -67,12 +67,12 @@ def test_manifest_name_is_non_empty_string(): def test_manifest_missing_name_rejected_by_schema(): doc = load_yaml_fixture("manifest", "invalid-missing-name.yml") with pytest.raises(jsonschema.ValidationError): - validate_against("manifest-v0.1.schema.json", doc) + validate_against("manifest-v0.1.41.schema.json", doc) @pytest.mark.req("req-mf-004") def test_manifest_version_is_semver_2_0_0(): - schema = load_schema("manifest-v0.1.schema.json") + schema = load_schema("manifest-v0.1.41.schema.json") pattern = schema["properties"]["version"]["pattern"] assert "0|[1-9]" in pattern, "version pattern must be semver 2.0.0 grammar" assert_spec_contains("semver 2.0.0", "version`") @@ -94,12 +94,12 @@ def test_manifest_target_enum_is_pinned(): def test_consumer_rejects_missing_source_key(): doc = load_yaml_fixture("manifest", "invalid-no-source-key.yml") with pytest.raises(jsonschema.ValidationError): - validate_against("manifest-v0.1.schema.json", doc) + validate_against("manifest-v0.1.41.schema.json", doc) @pytest.mark.req("req-mf-007") def test_consumer_apm_source_field_has_supported_shapes(): - schema = load_schema("manifest-v0.1.schema.json") + schema = load_schema("manifest-v0.1.41.schema.json") entry = schema["$defs"]["depEntry"] one_of = entry["oneOf"] has_string = any(s.get("type") == "string" for s in one_of) @@ -111,14 +111,14 @@ def test_consumer_apm_source_field_has_supported_shapes(): @pytest.mark.req("req-mf-008") def test_consumer_supports_pinned_version(): - schema = load_schema("manifest-v0.1.schema.json") + schema = load_schema("manifest-v0.1.41.schema.json") entry_obj = next(s for s in schema["$defs"]["depEntry"]["oneOf"] if s.get("type") == "object") assert "version" in entry_obj["properties"] @pytest.mark.req("req-mf-009") def test_consumer_supports_pinned_commit(): - schema = load_schema("manifest-v0.1.schema.json") + schema = load_schema("manifest-v0.1.41.schema.json") entry_obj = next(s for s in schema["$defs"]["depEntry"]["oneOf"] if s.get("type") == "object") assert "ref" in entry_obj["properties"], ( "depEntry MUST permit a `ref` field for commit / branch / tag pins" @@ -127,7 +127,7 @@ def test_consumer_supports_pinned_commit(): @pytest.mark.req("req-mf-010") def test_consumer_supports_apm_source_short_form_string(): - schema = load_schema("manifest-v0.1.schema.json") + schema = load_schema("manifest-v0.1.41.schema.json") one_of = schema["$defs"]["depEntry"]["oneOf"] string_form = next(s for s in one_of if s.get("type") == "string") assert string_form.get("minLength", 0) >= 1 @@ -135,7 +135,7 @@ def test_consumer_supports_apm_source_short_form_string(): @pytest.mark.req("req-mf-011") def test_consumer_supports_apm_source_table_form(): - schema = load_schema("manifest-v0.1.schema.json") + schema = load_schema("manifest-v0.1.41.schema.json") entry_obj = next(s for s in schema["$defs"]["depEntry"]["oneOf"] if s.get("type") == "object") options = entry_obj["oneOf"] required_sets = sorted(tuple(sorted(o["required"])) for o in options) @@ -148,12 +148,12 @@ def test_consumer_supports_apm_source_table_form(): def test_consumer_rejects_unknown_source_kind(): doc = load_yaml_fixture("manifest", "invalid-source-kind.yml") with pytest.raises(jsonschema.ValidationError): - validate_against("manifest-v0.1.schema.json", doc) + validate_against("manifest-v0.1.41.schema.json", doc) @pytest.mark.req("req-mf-013") def test_consumer_supports_local_path_source(): - schema = load_schema("manifest-v0.1.schema.json") + schema = load_schema("manifest-v0.1.41.schema.json") entry_obj = next(s for s in schema["$defs"]["depEntry"]["oneOf"] if s.get("type") == "object") assert "path" in entry_obj["properties"] @@ -161,22 +161,22 @@ def test_consumer_supports_local_path_source(): @pytest.mark.req("req-mf-014") def test_producer_rejects_non_http_registry_scheme(): """Schema pattern `^https?://` is the regression handle.""" - schema = load_schema("manifest-v0.1.schema.json") + schema = load_schema("manifest-v0.1.41.schema.json") reg = schema["properties"]["registries"]["additionalProperties"]["oneOf"][1] assert reg["properties"]["url"]["pattern"] == "^https?://" doc = load_yaml_fixture("manifest", "invalid-registry-scheme.yml") with pytest.raises(jsonschema.ValidationError): - validate_against("manifest-v0.1.schema.json", doc) + validate_against("manifest-v0.1.41.schema.json", doc) @pytest.mark.req("req-mf-015") def test_producer_rejects_unknown_registries_keys(): - schema = load_schema("manifest-v0.1.schema.json") + schema = load_schema("manifest-v0.1.41.schema.json") reg = schema["properties"]["registries"]["additionalProperties"]["oneOf"][1] assert reg["additionalProperties"] is False doc = load_yaml_fixture("manifest", "invalid-registries-typo.yml") with pytest.raises(jsonschema.ValidationError): - validate_against("manifest-v0.1.schema.json", doc) + validate_against("manifest-v0.1.41.schema.json", doc) @pytest.mark.req("req-mf-016") @@ -199,14 +199,14 @@ def test_producer_publishes_apm_yml_at_repo_root(): @pytest.mark.req("req-mf-018") def test_consumer_restricts_policy_hash_algorithm_to_strong_set(): - schema = load_schema("manifest-v0.1.schema.json") + schema = load_schema("manifest-v0.1.41.schema.json") enum = schema["properties"]["policy"]["properties"]["hash_algorithm"]["enum"] assert set(enum) == {"sha256", "sha384", "sha512"} @pytest.mark.req("req-mf-019") def test_consumer_supports_default_host_field(): - schema = load_schema("manifest-v0.1.schema.json") + schema = load_schema("manifest-v0.1.41.schema.json") assert "default_host" in schema["properties"] doc = load_yaml_fixture("manifest", "x-extension-roundtrip.yml") assert doc.get("default_host"), "fixture must exercise default_host" @@ -456,7 +456,7 @@ def test_consumer_preserves_x_extension_keys_on_round_trip(): doc = load_yaml_fixture("manifest", "x-extension-roundtrip.yml") x_keys = [k for k in doc if k.startswith("x-")] assert x_keys, "fixture must contain at least one x-* key" - schema = load_schema("manifest-v0.1.schema.json") + schema = load_schema("manifest-v0.1.41.schema.json") pp = schema.get("patternProperties", {}) assert any(k.startswith("^x-") for k in pp), ( "manifest schema MUST declare patternProperties for x-* keys" @@ -844,19 +844,19 @@ def test_dependency_package_targets_are_restriction_only() -> None: assert disjoint.targets == () assert tuple(target.name for target in universal.targets) == ("cursor",) - schema = load_schema("manifest-v0.1.schema.json") + schema = load_schema("manifest-v0.1.41.schema.json") jsonschema.Draft202012Validator.check_schema(schema) validate_against( - "manifest-v0.1.schema.json", + "manifest-v0.1.41.schema.json", {"name": "claude-hooks", "version": "1.0.0", "targets": ["claude"]}, ) validate_against( - "manifest-v0.1.schema.json", + "manifest-v0.1.41.schema.json", {"name": "legacy-null", "version": "1.0.0", "target": None}, ) with pytest.raises(jsonschema.ValidationError): validate_against( - "manifest-v0.1.schema.json", + "manifest-v0.1.41.schema.json", { "name": "conflicting-hooks", "version": "1.0.0", @@ -866,13 +866,13 @@ def test_dependency_package_targets_are_restriction_only() -> None: ) with pytest.raises(jsonschema.ValidationError): validate_against( - "manifest-v0.1.schema.json", + "manifest-v0.1.41.schema.json", {"name": "blank-target", "version": "1.0.0", "targets": [""]}, ) for malformed_token in ("Cursor", "../cursor"): with pytest.raises(jsonschema.ValidationError): validate_against( - "manifest-v0.1.schema.json", + "manifest-v0.1.41.schema.json", { "name": "malformed-target", "version": "1.0.0", @@ -887,7 +887,7 @@ def test_dependency_package_targets_are_restriction_only() -> None: ): with pytest.raises(jsonschema.ValidationError): validate_against( - "manifest-v0.1.schema.json", + "manifest-v0.1.41.schema.json", {"name": "invalid-target", "version": "1.0.0", **invalid_fields}, ) assert_spec_contains( diff --git a/tests/spec_conformance/test_resolution_reqs.py b/tests/spec_conformance/test_resolution_reqs.py index 57c1aaca72..72bed254a1 100644 --- a/tests/spec_conformance/test_resolution_reqs.py +++ b/tests/spec_conformance/test_resolution_reqs.py @@ -187,7 +187,7 @@ def test_resolver_fails_closed_on_ambiguous_resolution(): ) # Schema enum pin (round-3 fold): the manifest schema MUST admit # only `intersection-pick` in v0.1; `nest` is reserved for v0.2. - schema = load_schema("manifest-v0.1.schema.json") + schema = load_schema("manifest-v0.1.41.schema.json") enum = schema["$defs"]["depsBlock"]["properties"]["conflict_resolution"]["enum"] assert enum == ["intersection-pick"], ( f"manifest schema conflict_resolution enum MUST be exactly " diff --git a/tests/test_apm_resolver.py b/tests/test_apm_resolver.py index e13f1699e0..ef52fa9ee2 100644 --- a/tests/test_apm_resolver.py +++ b/tests/test_apm_resolver.py @@ -612,12 +612,18 @@ def test_local_parent_local_path_not_rejected_or_tracked(self): ) def test_remote_parent_same_repo_sibling_path_expands_to_remote_virtual_dep(self): + for alias in (None, ".safe", "safe.", "foo..bar", "my-skill.v2"): + with self.subTest(alias=alias): + self._assert_same_repo_remote_sibling(alias) + + def _assert_same_repo_remote_sibling(self, alias): with TemporaryDirectory() as tmpdir: project_root = Path(tmpdir) / "consumer" modules_dir = project_root / "apm_modules" project_root.mkdir() + alias_line = f" alias: {alias}\n" if alias else "" (project_root / "apm.yml").write_text( - """ + f""" name: consumer version: 1.0.0 dependencies: @@ -625,7 +631,7 @@ def test_remote_parent_same_repo_sibling_path_expands_to_remote_virtual_dep(self - git: microsoft/mono path: packages/frontend ref: feature -""".lstrip() +{alias_line}""".lstrip() ) callback_refs = [] @@ -669,6 +675,8 @@ def download_callback(dep_ref, apm_modules_dir, parent_chain="", parent_pkg=None self.assertFalse(Path(shared.dependency_ref.virtual_path).is_absolute()) self.assertFalse(PureWindowsPath(shared.dependency_ref.virtual_path).is_absolute()) self.assertEqual(shared.dependency_ref.reference, "feature") + self.assertEqual(shared.dependency_ref.host, callback_refs[0].host) + self.assertEqual(shared.dependency_ref.port, callback_refs[0].port) self.assertFalse(shared.dependency_ref.is_local) self.assertEqual(resolver._rejected_remote_local_keys, set()) self.assertTrue( @@ -679,34 +687,42 @@ def download_callback(dep_ref, apm_modules_dir, parent_chain="", parent_pkg=None ) def test_remote_parent_path_escape_outside_repo_root_is_rejected(self): - self._assert_remote_local_path_rejected( - parent_path="packages/frontend", - child_path="../../../outside", - rejected_key="../../../outside", - ) + for alias in (None, ".safe"): + with self.subTest(alias=alias): + self._assert_remote_local_path_rejected( + parent_path="packages/frontend", + child_path="../../../outside", + rejected_key="../../../outside", + alias=alias, + ) def test_remote_parent_path_to_different_repo_clone_is_rejected(self): - self._assert_remote_local_path_rejected( - parent_path=None, - child_path="../repo-b/shared", - rejected_key="../repo-b/shared", - ) + for alias in (None, ".safe"): + with self.subTest(alias=alias): + self._assert_remote_local_path_rejected( + parent_path=None, + child_path="../repo-b/shared", + rejected_key="../repo-b/shared", + alias=alias, + ) def _assert_remote_local_path_rejected( - self, *, parent_path: str | None, child_path: str, rejected_key: str + self, *, parent_path: str | None, child_path: str, rejected_key: str, alias=None ): with TemporaryDirectory() as tmpdir: project_root = Path(tmpdir) / "consumer" modules_dir = project_root / "apm_modules" project_root.mkdir() if parent_path is None: - dep_block = " - microsoft/repo-a\n" + dep_block = " - git: microsoft/repo-a\n" parent_virtual_path = None else: dep_block = ( f" - git: microsoft/repo-a\n path: {parent_path}\n ref: main\n" ) parent_virtual_path = parent_path + if alias: + dep_block += f" alias: {alias}\n" (project_root / "apm.yml").write_text( f""" name: consumer diff --git a/tests/unit/cache/test_git_cache.py b/tests/unit/cache/test_git_cache.py index 5b8941678f..e98a0d72fb 100644 --- a/tests/unit/cache/test_git_cache.py +++ b/tests/unit/cache/test_git_cache.py @@ -10,6 +10,51 @@ import pytest from apm_cli.cache.git_cache import CachePruneError, GitCache +from apm_cli.cache.url_normalize import cache_shard_key + + +@pytest.mark.parametrize("ref", ["main", "release/v1", "v1.0"]) +def test_remote_ref_receipt_is_shared_without_rewriting_git_refs(tmp_path: Path, ref: str) -> None: + """Fresh named observations survive cache instances without changing pins.""" + cache = GitCache(tmp_path) + url = "https://github.com/owner/repo" + assert cache.read_resolved_ref(url, ref) == (False, None) + cache.remember_resolved_ref(url, ref, "a" * 40) + cache.remember_resolved_ref(url, ref, "b" * 40) + replay = GitCache(tmp_path) + assert replay.read_resolved_ref(url + ".git", ref) == (True, "b" * 40) + assert replay.read_resolved_ref(url, ref + "-other") == (False, None) + assert replay.read_resolved_ref(url + "-other", ref) == (False, None) + assert replay._resolve_sha(url, ref, locked_sha="a" * 40) == "a" * 40 + assert replay.read_resolved_ref(url, ref) == (True, "b" * 40) + + +@pytest.mark.parametrize("count", [2, 20]) +def test_ref_receipt_stats_count_bytes_not_repositories(tmp_path: Path, count: int) -> None: + """Receipt accounting is linear and does not count lock files as content.""" + cache = GitCache(tmp_path) + for index in range(count): + cache.remember_resolved_ref("https://github.com/owner/repo", f"ref-{index}", "a" * 40) + with patch("apm_cli.cache.git_cache.os.scandir", wraps=os.scandir) as scandir: + stats = cache.get_cache_stats() + assert stats == {"db_count": 0, "checkout_count": 0, "total_size_bytes": 40 * count} + assert scandir.call_count == 2 + + +def test_corrupt_remote_ref_receipt_does_not_revive_stale_bare_ref(tmp_path: Path) -> None: + """An unusable newer observation is a cache miss, not an older answer.""" + from apm_cli.deps.tiered_ref_resolver import L2BareRevParse + from apm_cli.models.apm_package import DependencyReference + + cache = GitCache(tmp_path) + dependency = DependencyReference.parse("owner/repo#main") + url = dependency.to_github_url() + cache.remember_resolved_ref(url, "main", "b" * 40) + cache._resolved_ref_path(url, "main").write_text("broken", encoding="ascii") + (cache._db_root / cache_shard_key(url)).mkdir() + with patch.object(L2BareRevParse, "_rev_parse", return_value="a" * 40) as bare: + assert L2BareRevParse(cache).try_resolve(dependency, "main") is None + bare.assert_not_called() @pytest.mark.windows_compat diff --git a/tests/unit/deps/test_github_downloader_phase3.py b/tests/unit/deps/test_github_downloader_phase3.py index a94751d515..4ae2dece8b 100644 --- a/tests/unit/deps/test_github_downloader_phase3.py +++ b/tests/unit/deps/test_github_downloader_phase3.py @@ -34,6 +34,37 @@ # --------------------------------------------------------------------------- +@pytest.mark.parametrize("remote_observation", [True, False]) +@pytest.mark.parametrize("anonymous_first", [True, False]) +def test_persistent_cache_only_records_authoritative_named_ref( + tmp_path: Path, remote_observation: bool, anonymous_first: bool +) -> None: + """Both auth paths record successful fresh observations, never old lock pins.""" + downloader = GitHubPackageDownloader.__new__(GitHubPackageDownloader) + downloader.auth_resolver = MagicMock() + downloader.auth_resolver.uses_public_github_anonymous_first.return_value = anonymous_first + downloader.auth_resolver.try_with_fallback.side_effect = lambda host, operation, **kwargs: ( + operation(None, {}) + ) + downloader.git_env = {} + downloader._cache_git_env = MagicMock(return_value={}) + downloader._tiered_resolver = MagicMock() + downloader._tiered_resolver.remotely_resolved.return_value = remote_observation + dependency = DependencyReference.parse("owner/repo#main") + cache = MagicMock() + cache.get_checkout.return_value = tmp_path + url = dependency.to_github_url() + assert ( + downloader._persistent_cache_checkout(cache, dependency, url, "b" * 40, locked_sha="b" * 40) + == tmp_path + ) + cache.get_checkout.assert_called_once() + if remote_observation: + cache.remember_resolved_ref.assert_called_once_with(url, "main", "b" * 40) + else: + cache.remember_resolved_ref.assert_not_called() + + def _make_dep( repo_url: str = "owner/repo", *, diff --git a/tests/unit/deps/test_lockfile_consumer_contract.py b/tests/unit/deps/test_lockfile_consumer_contract.py index 23bb8b1d3b..ac2abdc1c5 100644 --- a/tests/unit/deps/test_lockfile_consumer_contract.py +++ b/tests/unit/deps/test_lockfile_consumer_contract.py @@ -17,6 +17,7 @@ _LOCKED_DEPENDENCY_VALUES = { "repo_url": "apm-org/apm-project/consume-contract", "materialization_repo_url": None, + "alias": "my-skill.v2", "host": "dev.azure.com", "host_type": "gitlab", "port": 2222, @@ -61,6 +62,7 @@ _RECONSTRUCTED_LOCK_FIELDS = { "repo_url", "materialization_repo_url", + "alias", "host", "host_type", "port", @@ -157,6 +159,7 @@ def test_reconstruction_declares_and_preserves_every_consumed_lock_field() -> No dependency = LockedDependency( repo_url="group/consume-contract", + alias="my-skill.v2", host="gitlab.example.invalid", host_type="gitlab", port=2222, @@ -178,6 +181,7 @@ def test_reconstruction_declares_and_preserves_every_consumed_lock_field() -> No reconstructed = dependency.to_dependency_ref() assert reconstructed.repo_url == dependency.repo_url + assert reconstructed.alias == dependency.alias assert reconstructed.host == dependency.host assert reconstructed.host_type == dependency.host_type assert reconstructed.port == dependency.port diff --git a/tests/unit/deps/test_lockfile_field_properties.py b/tests/unit/deps/test_lockfile_field_properties.py index e6a08e4cde..a793c756b5 100644 --- a/tests/unit/deps/test_lockfile_field_properties.py +++ b/tests/unit/deps/test_lockfile_field_properties.py @@ -101,6 +101,7 @@ def maybe(default: Any, value: st.SearchStrategy[Any]) -> Any: kwargs: dict[str, Any] = {"repo_url": _repo_url(draw)} kwargs["materialization_repo_url"] = None + kwargs["alias"] = maybe(None, st.sampled_from([".safe", "safe.", "foo..bar", "my-skill.v2"])) kwargs["host"] = maybe(None, st.sampled_from(["gitlab.example.invalid", "git.example.invalid"])) kwargs["host_type"] = maybe(None, st.just("gitlab")) kwargs["port"] = maybe(None, st.integers(min_value=1, max_value=65535)) @@ -627,10 +628,12 @@ def test_retired_key_property_breaks_if_transient_guard_is_disabled( @seed(PROPERTY_SEED) @PROPERTY_PROFILE @given( - alias=st.text(alphabet=string.ascii_letters + string.digits + "._-", min_size=1, max_size=12) + alias=st.text( + alphabet=string.ascii_letters + string.digits + "._-", min_size=1, max_size=12 + ).filter(lambda value: value not in {".", ".."}) ) -def test_alias_never_persists_into_lock_state(alias: str) -> None: - """``alias`` is a declared-manifest concept; it must never reach the lockfile.""" +def test_alias_persists_as_placement_not_identity(alias: str) -> None: + """Alias placement survives serialization without changing the source key.""" dep_ref = DependencyReference( repo_url="acme/example", host="gitlab.example.invalid", alias=alias ) @@ -640,43 +643,44 @@ def test_alias_never_persists_into_lock_state(alias: str) -> None: ) persisted = locked.to_dict() - assert "alias" not in persisted - assert not any("alias" in key for key in persisted) - reconstructed = locked.to_dependency_ref() - assert reconstructed.alias is None + assert persisted["alias"] == alias + reconstructed = LockedDependency.from_dict(persisted).to_dependency_ref() + assert reconstructed.alias == alias + assert reconstructed.get_unique_key() == dep_ref.get_unique_key() @seed(PROPERTY_SEED) @SINGLE_EXAMPLE_PROFILE @given( - alias=st.text(alphabet=string.ascii_letters + string.digits + "._-", min_size=1, max_size=12) + alias=st.text( + alphabet=string.ascii_letters + string.digits + "._-", min_size=1, max_size=12 + ).filter(lambda value: value not in {".", ".."}) ) -def test_alias_never_persists_property_breaks_if_alias_leaks_into_unknown_fields( +def test_alias_placement_property_breaks_if_reconstruction_drops_alias( alias: str, ) -> None: - """Negative twin: a leaked alias in ``_unknown_fields`` must break the guard.""" + """Negative twin: dropping placement during lock assembly breaks replay.""" dep_ref = DependencyReference( repo_url="acme/example", host="gitlab.example.invalid", alias=alias ) original = LockedDependency.from_dependency_ref - def leaking_from_dependency_ref(*args: Any, **kwargs: Any) -> LockedDependency: + def dropping_from_dependency_ref(*args: Any, **kwargs: Any) -> LockedDependency: locked = original(*args, **kwargs) - locked._unknown_fields = {**locked._unknown_fields, "alias": dep_ref.alias} + locked.alias = None return locked with pytest.MonkeyPatch.context() as mp: mp.setattr( LockedDependency, "from_dependency_ref", - classmethod(lambda cls, *a, **k: leaking_from_dependency_ref(*a, **k)), + classmethod(lambda cls, *a, **k: dropping_from_dependency_ref(*a, **k)), ) locked = LockedDependency.from_dependency_ref( dep_ref, resolved_commit="a" * 40, depth=1, resolved_by=None ) - persisted = locked.to_dict() with pytest.raises(AssertionError): - assert "alias" not in persisted + assert locked.to_dependency_ref().alias == alias # -------------------------------------------------------------------------- diff --git a/tests/unit/deps/test_tiered_ref_resolver.py b/tests/unit/deps/test_tiered_ref_resolver.py index cc86ce9b30..a9b108bd4d 100644 --- a/tests/unit/deps/test_tiered_ref_resolver.py +++ b/tests/unit/deps/test_tiered_ref_resolver.py @@ -193,7 +193,9 @@ def test_l2_returns_none_when_no_git_cache(): def test_l2_returns_none_when_bare_dir_missing(tmp_path): - fake_cache = types.SimpleNamespace(_db_root=tmp_path / "nonexistent") + fake_cache = types.SimpleNamespace( + _db_root=tmp_path / "nonexistent", read_resolved_ref=lambda *_: (False, None) + ) tier = L2BareRevParse(git_cache=fake_cache) assert tier.try_resolve(_dep(), "main") is None @@ -218,7 +220,9 @@ def test_l2_short_circuits_on_sha_input(): def test_l2_rev_parse_uses_git_cache_repository_identity(tmp_path, dependency): bare = tmp_path / cache_shard_key(dependency.to_github_url()) bare.mkdir(parents=True) - fake_cache = types.SimpleNamespace(_db_root=tmp_path) + fake_cache = types.SimpleNamespace( + _db_root=tmp_path, read_resolved_ref=lambda *_: (False, None) + ) tier = L2BareRevParse(git_cache=fake_cache) with patch.object(L2BareRevParse, "_rev_parse", return_value=SHA_A) as rp: @@ -306,6 +310,33 @@ def test_orchestrator_caches_after_first_resolve(): assert resolver.stats["per_run_cache"] == 2 +def test_only_actual_current_remote_resolution_can_publish_named_observation(): + """Neither an older lock seed nor a SHA passthrough authorizes publication.""" + cache = PerRunRefCache() + remote = MagicMock() + remote.name = "commits_api" + remote.try_resolve.return_value = SHA_B + legacy = _make_legacy_with(SHA_B) + resolver = TieredRefResolver( + tiers=[L0PerRunCache(cache=cache), remote, legacy], + cache=cache, + legacy=legacy, + freshness_policy=RefFreshnessPolicy.CURRENT_REMOTE, + ) + dependency = _dep(ref="main") + assert resolver.remotely_resolved(dependency, SHA_B) is False + resolver.resolve(dependency) + resolver.resolve(dependency) + assert resolver.remotely_resolved(dependency, SHA_B) is True + assert resolver.remotely_resolved(dependency, SHA_A) is False + assert resolver.remotely_resolved(_dep(ref="other"), SHA_B) is False + resolver.seed(_dep(ref="old"), "old", SHA_A) + resolver.resolve(_dep(ref="old")) + resolver.resolve(_dep(ref=SHA_A)) + assert resolver.remotely_resolved(_dep(ref="old"), SHA_A) is False + assert resolver.remotely_resolved(_dep(ref=SHA_A), SHA_A) is False + + def test_seed_populates_l0_and_avoids_network_tier(): """A seeded ref resolves via L0 without touching the network tier.""" cache = PerRunRefCache() @@ -569,7 +600,7 @@ def test_stale_bare_bypassed_on_update(monkeypatch, tmp_path): bare = tmp_path / cache_shard_key(_dep().to_github_url()) bare.mkdir(parents=True) - git_cache = types.SimpleNamespace(_db_root=tmp_path) + git_cache = types.SimpleNamespace(_db_root=tmp_path, read_resolved_ref=lambda *_: (False, None)) downloader = MagicMock() fake_refs = MagicMock() fake_refs.resolve_commit_sha_for_ref.return_value = None @@ -604,7 +635,7 @@ def test_normal_policy_uses_l2_when_api_unavailable_without_clone(monkeypatch, t monkeypatch.setenv("APM_TIERED_RESOLVER", "1") bare = tmp_path / cache_shard_key(_dep().to_github_url()) bare.mkdir(parents=True) - git_cache = types.SimpleNamespace(_db_root=tmp_path) + git_cache = types.SimpleNamespace(_db_root=tmp_path, read_resolved_ref=lambda *_: (False, None)) downloader = MagicMock() fake_refs = MagicMock() fake_refs.resolve_commit_sha_for_ref.return_value = None diff --git a/tests/unit/test_alias_traversal.py b/tests/unit/test_alias_traversal.py new file mode 100644 index 0000000000..5eeda9be4e --- /dev/null +++ b/tests/unit/test_alias_traversal.py @@ -0,0 +1,166 @@ +"""Unit tests for traversal alias rejection (alias-poisoning fix).""" + +from pathlib import Path + +import pytest + +from apm_cli.models.dependency import DependencyReference +from apm_cli.models.dependency.object_fields import parse_alias_override +from apm_cli.utils.path_security import PathTraversalError, ensure_path_within + + +class TestParseAliasOverrideTraversal: + """Reject aliases that navigate outside apm_modules.""" + + def test_single_dot_rejected(self): + with pytest.raises(ValueError, match="reserved directory names"): + parse_alias_override(".") + + def test_double_dot_rejected(self): + with pytest.raises(ValueError, match="reserved directory names"): + parse_alias_override("..") + + @pytest.mark.parametrize("alias", [".", ".."]) + @pytest.mark.parametrize( + "prefix", ["ssh://git@github.com/org/pkg.git@", "git@github.com:org/pkg.git@"] + ) + def test_ssh_alias_uses_shared_validation(self, prefix, alias): + with pytest.raises(ValueError, match="reserved directory names"): + DependencyReference.parse(prefix + alias) + + def test_traversal_segment_rejected(self): + with pytest.raises( + ValueError, + match="Aliases can only contain letters, numbers, dots, underscores, and hyphens", + ): + parse_alias_override("foo/../bar") + + def test_leading_dot_slash_rejected(self): + with pytest.raises( + ValueError, + match="Aliases can only contain letters, numbers, dots, underscores, and hyphens", + ): + parse_alias_override("./safe-name") + + def test_trailing_dot_dot_rejected(self): + with pytest.raises( + ValueError, + match="Aliases can only contain letters, numbers, dots, underscores, and hyphens", + ): + parse_alias_override("pkg/..") + + def test_encoded_dot_dot_rejected(self): + with pytest.raises( + ValueError, + match="Aliases can only contain letters, numbers, dots, underscores, and hyphens", + ): + parse_alias_override("%2e%2e") + + def test_double_encoded_dot_dot_rejected(self): + with pytest.raises( + ValueError, + match="Aliases can only contain letters, numbers, dots, underscores, and hyphens", + ): + parse_alias_override("%252e%252e") + + +class TestParseAliasOverrideSafe: + """Safe aliases must still be accepted.""" + + def test_simple_name(self): + assert parse_alias_override("my-skill") == "my-skill" + + def test_name_with_dots(self): + assert parse_alias_override("my-skill.v2") == "my-skill.v2" + + @pytest.mark.parametrize("alias", [".safe", "safe.", "foo..bar", "my-skill.v2"]) + def test_safe_dotted_alias_preserves_spelling(self, alias): + assert parse_alias_override(alias) == alias + ref = DependencyReference.parse("ssh://git@github.com/org/pkg.git@" + alias) + assert ref.alias == alias + + def test_deep_dotted_name(self): + assert parse_alias_override("deep.nested.name") == "deep.nested.name" + + def test_underscore_and_hyphen(self): + assert parse_alias_override("skill_v2-beta") == "skill_v2-beta" + + def test_none_returns_none(self): + assert parse_alias_override(None) is None + + def test_empty_string_rejected(self): + with pytest.raises(ValueError): + parse_alias_override("") + + def test_whitespace_only_rejected(self): + with pytest.raises(ValueError): + parse_alias_override(" ") + + def test_invalid_chars_rejected(self): + with pytest.raises(ValueError): + parse_alias_override("bad alias!") + + +class TestInstallPhaseSymlinkEscape: + """A symlinked alias directory cannot move the write target outside apm_modules. + + Parse-time validation (``parse_alias_override``) accepts a syntactically + valid alias like ``safe-name`` regardless of what already exists on disk. + The escape vector is the *filesystem*: if ``apm_modules_dir/safe-name`` is a + symlink pointing outside ``apm_modules_dir``, then writing through the + alias path would escape the managed tree. ``ensure_path_within`` is uniquely + load-bearing here because it resolves symlinks before checking containment; + both install phases delegate to the materialization owner, which invokes + it for every aliased dependency. + """ + + def test_symlink_escape_is_blocked(self, tmp_path): + apm_modules_dir = tmp_path / "apm_modules" + apm_modules_dir.mkdir() + outside = tmp_path / "escape_target" + outside.mkdir() + sentinel = outside / "secret.txt" + sentinel.write_text("do not escape") + + # Parse-time validation accepts the alias "safe-name"; the escape + # vector is the filesystem: apm_modules_dir/safe-name is a symlink + # planted outside apm_modules_dir. + (apm_modules_dir / "safe-name").symlink_to(outside) + + # Construct the same path the install phases build for an aliased + # dependency: (apm_modules_dir / dep_ref.alias) == apm_modules/safe-name + alias_path = apm_modules_dir / "safe-name" + + with pytest.raises(PathTraversalError): + ensure_path_within(alias_path, apm_modules_dir) + + # The escape was blocked, never followed: the outside target is intact. + assert sentinel.read_text() == "do not escape" + + @pytest.mark.windows_compat + @pytest.mark.parametrize("target", ["root", "child", "outside"]) + def test_extended_prefix_alias_destination(self, tmp_path, monkeypatch, target): + """Emulate Windows resolve spelling without requiring junction privileges.""" + modules = tmp_path / "apm_modules" + alias_path = modules / "safe-name" + destination = { + "root": modules, + "child": modules / "child", + "outside": tmp_path / "outside", + }[target] + original_resolve = Path.resolve + + def resolve_with_prefix(path, *args, **kwargs): + if path == alias_path: + return Path("\\\\?\\" + str(destination)) + if path == modules: + return Path("\\\\?\\" + str(modules)) + return original_resolve(path, *args, **kwargs) + + monkeypatch.setattr(Path, "resolve", resolve_with_prefix) + dependency = DependencyReference(repo_url="org/pkg", alias="safe-name") + if target == "child": + assert dependency.get_install_path(modules) == alias_path + else: + with pytest.raises(PathTraversalError): + dependency.get_install_path(modules) diff --git a/tests/unit/test_command_helpers.py b/tests/unit/test_command_helpers.py index c0d673aa73..804ff04ee0 100644 --- a/tests/unit/test_command_helpers.py +++ b/tests/unit/test_command_helpers.py @@ -226,15 +226,13 @@ def test_finds_ado_style_3level_packages(self, tmp_path): found = [p for p in result if "org/project/repo" in p] assert len(found) >= 1 - def test_ignores_dot_named_directories(self, tmp_path): - """Directories whose own name starts with '.' are skipped.""" - # A directory named '.hidden' at top-level is skipped by name check. + def test_includes_dot_named_top_level_packages(self, tmp_path): + """A valid dotted alias with package metadata is an installed package.""" dot_dir = tmp_path / ".hidden" dot_dir.mkdir() (dot_dir / "apm.yml").write_text("name: hidden") result = _scan_installed_packages(tmp_path) - # rel_parts of ".hidden" has length 1, so it can't produce an owner/repo key - assert not any(p == ".hidden" for p in result) + assert result == [".hidden"] def test_ignores_dirs_without_apm_marker(self, tmp_path): """Directories without apm.yml or .apm are not returned.""" diff --git a/tests/unit/test_deps_utils.py b/tests/unit/test_deps_utils.py index 1933f6cd27..7247266e71 100644 --- a/tests/unit/test_deps_utils.py +++ b/tests/unit/test_deps_utils.py @@ -5,7 +5,7 @@ from pathlib import Path -import pytest # noqa: F401 +import pytest from apm_cli.commands.deps._utils import ( _count_package_files, @@ -96,6 +96,25 @@ def test_nested_multiple_levels_deep(self, tmp_path): assert _is_nested_under_package(deep, modules) is True +@pytest.mark.parametrize("alias", [".safe", "safe.", "foo..bar", "my-skill.v2"]) +def test_scan_includes_flattened_alias_without_nested_or_symlink_packages( + tmp_path: Path, alias: str +) -> None: + """Prune must see an alias root, but not its contents or external links.""" + modules = tmp_path / "apm_modules" + package = modules / alias + package.mkdir(parents=True) + _make_apm_yml(package) + nested = package / "nested" + nested.mkdir() + _make_apm_yml(nested) + outside = tmp_path / "outside" + outside.mkdir() + _make_apm_yml(outside) + (modules / "linked").symlink_to(outside, target_is_directory=True) + assert _scan_installed_packages(modules) == [alias] + + # ================================================================== # _count_primitives # ================================================================== @@ -517,10 +536,9 @@ def test_packages_with_dot_apm_dir(self, tmp_path): result = _scan_installed_packages(tmp_path) assert "org/repo" in result - def test_single_level_dirs_excluded(self, tmp_path): - """Single-level paths (just 'org') are not included.""" + def test_single_level_namespace_dirs_excluded(self, tmp_path): + """A namespace directory without package metadata is not a package.""" org = tmp_path / "justorg" org.mkdir() - _make_apm_yml(org, "justorg") result = _scan_installed_packages(tmp_path) assert result == [] diff --git a/tests/unit/test_dev_dependencies.py b/tests/unit/test_dev_dependencies.py index 0b51a1a83c..6c35a520f1 100644 --- a/tests/unit/test_dev_dependencies.py +++ b/tests/unit/test_dev_dependencies.py @@ -92,6 +92,7 @@ class TestFromInstalledPackagesIsDev: def _mock_dep_ref(self, repo_url): ref = Mock() ref.repo_url = repo_url + ref.alias = None ref.host = None ref.host_type = None ref.port = None diff --git a/tests/unit/test_registry_entry_alias_traversal.py b/tests/unit/test_registry_entry_alias_traversal.py new file mode 100644 index 0000000000..31923be87c --- /dev/null +++ b/tests/unit/test_registry_entry_alias_traversal.py @@ -0,0 +1,70 @@ +"""Registry aliases delegate character and reserved-name checks to the shared parser.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from apm_cli.models.dependency.registry_entry import parse_registry_object_entry +from apm_cli.utils.path_security import PathTraversalError + + +@pytest.fixture(autouse=True) +def _enable_registry_feature(): + with patch( + "apm_cli.deps.registry.feature_gate.require_package_registry_enabled", + return_value=None, + ): + yield + + +def _make_reference_cls() -> type: + """A DependencyReference stand-in that records what it was constructed with.""" + instance = MagicMock() + the_cls = MagicMock(return_value=instance) + return the_cls + + +class TestRegistryObjectEntryTraversalAlias: + def test_dotdot_alias_rejected(self): + """A bare '..' alias passes the regex but must be caught by + validate_path_segments.""" + cls = _make_reference_cls() + with pytest.raises((ValueError, PathTraversalError)): + parse_registry_object_entry( + cls, + {"id": "org/pkg", "version": "v1", "alias": ".."}, + ) + + def test_dot_alias_rejected(self): + """A bare dot passes the character regex but names the modules root.""" + cls = _make_reference_cls() + with pytest.raises(ValueError, match="reserved directory names"): + parse_registry_object_entry( + cls, + {"id": "org/pkg", "version": "v1", "alias": "."}, + ) + + def test_encoded_dotdot_alias_rejected(self): + """Percent-encoded '..' contains '%' which fails _ALIAS_PATTERN.""" + cls = _make_reference_cls() + with pytest.raises((ValueError, PathTraversalError)): + parse_registry_object_entry( + cls, + {"id": "org/pkg", "version": "v1", "alias": "%2e%2e"}, + ) + + +class TestRegistryObjectEntrySafeAlias: + def test_safe_alias_accepted(self): + """A normal dotted / hyphenated alias must flow through unaffected.""" + cls = _make_reference_cls() + instance = parse_registry_object_entry( + cls, + {"id": "org/pkg", "version": "v1", "alias": "my-skill.v2"}, + ) + + assert instance is not None + _, kwargs = cls.call_args + assert kwargs["alias"] == "my-skill.v2"