From d68e0bd92c8881e492c33555f7229d0bd8fa281a Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sat, 5 Sep 2026 10:40:15 +0200 Subject: [PATCH 01/34] fix(install): preserve local transitive anchors at user scope Centralize local scope admission in user_scope_rejection_reason and retain declaring-local-parent source context in resolution and materialization. Keep direct relative inputs rejected and existing containment boundaries unchanged. Add production-boundary and installed-CLI regression/control coverage, architecture ownership guardrails, and focused docs. Fixes #2815. Phase 2 follow-up to #2813; the separate normative discrepancy is tracked in #2818. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../owners/install-deployment.json | 7 + .../src/content/docs/reference/cli/install.md | 6 + .../.apm/skills/apm-usage/commands.md | 4 + .../.apm/skills/apm-usage/dependencies.md | 13 +- .../checks/install_deployment_analyzers.py | 7 + .../checks/install_request_and_source.py | 46 +++++++ src/apm_cli/install/package_resolution.py | 28 +++- src/apm_cli/install/phases/resolve.py | 12 +- src/apm_cli/install/sources.py | 35 +++-- .../test_architecture_owner_rule_mutations.py | 8 ++ ..._global_local_transitive_dependency_e2e.py | 123 ++++++++++++++++++ .../install/test_local_scope_admission.py | 71 ++++++++++ .../test_user_scope_rejection_reason.py | 59 ++++++++- 13 files changed, 379 insertions(+), 40 deletions(-) create mode 100644 tests/integration/test_global_local_transitive_dependency_e2e.py create mode 100644 tests/unit/install/test_local_scope_admission.py diff --git a/.apm/architecture/owners/install-deployment.json b/.apm/architecture/owners/install-deployment.json index a0872223aa..461f1ce478 100644 --- a/.apm/architecture/owners/install-deployment.json +++ b/.apm/architecture/owners/install-deployment.json @@ -95,6 +95,13 @@ "selectors": ["src/apm_cli/commands/install.py"], "guards": ["install-deployment-install-scope-selection"] }, + { + "id": "local-dependency-scope-admission", + "decision": "Local dependency admission at user scope", + "owner": "install/package_resolution.py (user_scope_rejection_reason)", + "selectors": ["src/apm_cli/install/package_resolution.py"], + "guards": ["install-deployment-local-scope-admission"] + }, { "id": "mcp-registry-url-resolution", "decision": "MCP registry URL resolution precedence", diff --git a/docs/src/content/docs/reference/cli/install.md b/docs/src/content/docs/reference/cli/install.md index 07607543f1..9f688d39a0 100644 --- a/docs/src/content/docs/reference/cli/install.md +++ b/docs/src/content/docs/reference/cli/install.md @@ -19,6 +19,12 @@ With no arguments it installs everything from `apm.yml`. With one or more `PACKA `PACKAGE_REF` accepts: shorthand (`owner/repo`), HTTPS or SSH Git URLs, FQDN shorthand (`host/owner/repo`), local paths (`./path`, `/abs/path`, `~/path`), packed bundles (`./bundle.zip`, `./bundle.tar.gz`), and marketplace refs (`NAME@MARKETPLACE[#ref]`). +With `--global`, direct local dependencies must use absolute paths (`~/path` +also works). A local package can still declare a relative child such as +`../child`: APM resolves it from that declaring package's original source +directory, not the current working directory or `~/.apm/`. Direct or unanchored +relative local references remain unsupported at user scope. + :::caution `http://` dependencies are refused unless you pass `--allow-insecure` (direct) or `--allow-insecure-host HOSTNAME` (transitive). ::: diff --git a/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md index aa5062c973..96dc507dd9 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/commands.md +++ b/packages/apm-guide/.apm/skills/apm-usage/commands.md @@ -9,6 +9,10 @@ ## Dependency management +For `apm install -g`, direct local dependencies require absolute paths +(`~/path` works). Relative children of local packages resolve from the +declaring package's original source directory; see [local-path anchoring](dependencies.md). + | Command | Purpose | Key flags | |---------|---------|-----------| | `apm install [PKGS...]` | Install APM, MCP, and LSP dependencies (supports APM packages, Claude skills (SKILL.md), and plugin collections (plugin.json)); one effective target decision drives package, MCP, and LSP phases; plain/frozen installs replay locked refs and cache state, while `--update` and `--refresh` require current upstream mutable refs; a successful non-dry-run install also reconciles deployed artifacts, lockfile ownership, and merge-hook config/sidecar entries for any target dropped from `targets:` | `--update` (deprecated; prefer `apm update`) refresh refs without accepting stale bare-cache answers, `--refresh` re-fetch all deps from upstream and re-resolve all ref pins, `--force` overwrite collisions and permit deployment after critical built-in scan findings (does NOT refresh refs by itself; `apm update --force` still requires upstream truth), `--frozen` CI-safe install that fails before any durable write when `apm.lock.yaml` is missing or out of sync with `apm.yml`, including MCP config state (mutually exclusive with `--update`, package additions, and `--mcp`; use normal install to create or repair lock state, then `apm audit` for SHA integrity), `--dry-run` (no package/deployment writes; positional packages and ref changes are previewed without changing an existing `apm.yml`; a newly bootstrapped manifest and explicit targets are kept), `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated, e.g. `--target claude,cursor`; resolution chain `--target` > apm.yml `targets:` > `apm config set target ...` > auto-detect; this decision is reused by package, MCP, and LSP phases; unresolved required service work fails non-zero before manifest or package writes, and native MCP/LSP write failures also fail non-zero; `intellij` is MCP-only and writes JetBrains Copilot's user-scope config; explicit lists are exact, so `intellij,claude` writes those two MCP configs and `all,intellij` adds JetBrains to `all`; on auto-bootstrap when no `apm.yml` exists, recognized manifest target(s) are persisted to the new manifest's `targets:` field so a later bare `apm update` reuses them; `--target all` deprecated, see `apm compile --all`; use `kiro` for Kiro IDE; use `grok-build` for stable Grok Build rules, agents, commands, skills, and `AGENTS.md`; use `copilot-cowork` with `--global` after `apm experimental enable copilot-cowork`; use `grok-cloud` after `apm experimental enable grok-cloud` to deploy skills only to `.grok/skills/`; use explicit target `hermes` to deploy skills and home-scoped MCP servers to `$HERMES_HOME/config.yaml` (or `~/.hermes/config.yaml` when unset or blank); run `apm compile` separately for `AGENTS.md`), `--dev`, `-g` global (MCP deploys only to user-scope runtimes: Copilot CLI, Claude Code, Codex CLI, Gemini CLI, Antigravity CLI, Kiro, Windsurf, JetBrains Copilot, and Hermes when selected explicitly), `--trust-transitive-mcp`, `--parallel-downloads N`, `--trust-bin` / `--no-trust-bin` (per-invocation consent for marketplace-plugin bin/ deployment: `--trust-bin` suppresses the trust-posture warning, `--no-trust-bin` skips bin/ even if policy allows; default deploys with a warning), `--allow-insecure`, `--allow-insecure-host HOSTNAME`, `--skill NAME` install named skills from a dependency that exposes selectable skills (repeatable; plugin manifests accept a leaf name or source-relative path under skills/; a CLI name or path that matches no declared skill fails before manifest or lockfile commit with available names; a stale persisted `skills:` pin that no longer matches an available source skill warns with the package, declared request names, and available names, and directs the user to edit `skills:` in apm.yml; persisted in apm.yml only on a successful CLI match; additive across separate installs -- a later `--skill X` adds to the existing pin (union) rather than replacing it, so previously deployed skills are never silently removed; `'*'` resets to the full bundle; drop a single skill by editing the `skills:` list in apm.yml then re-running install), `--legacy-skill-paths` restore per-client skill dirs, `--mcp NAME` add MCP entry using that same effective target decision (the shared decision applies, so `apm install --mcp NAME --target intellij` writes only JetBrains Copilot's MCP config; compilation target policy applies to every explicitly selected target; `apm install -g --mcp NAME` writes user-scope and bypasses the project-scope gate by design), `--transport`, `--url`, `--env KEY=VAL`, `--header KEY=VAL`, `--mcp-version`, `--registry URL` custom MCP registry, `--root DIR` redirect writes (`apm_modules/`, lockfile, `.gitignore`, integrated harness files) under DIR while `apm.yml`/`.apm/`/local deps resolve from `$PWD` (mirrors `pip install --target`; created if missing; not valid with `-g`/`--global`, which exits 2). Explicit plugin component paths must resolve inside the plugin root; missing declarations fail before deployment and lockfile commit. | diff --git a/packages/apm-guide/.apm/skills/apm-usage/dependencies.md b/packages/apm-guide/.apm/skills/apm-usage/dependencies.md index e4efb1d83c..f1ae28b73b 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/dependencies.md +++ b/packages/apm-guide/.apm/skills/apm-usage/dependencies.md @@ -52,12 +52,13 @@ install for manual inspection. Repository path casing remains identity-significant for unknown git hosts because a self-hosted backend may be case-sensitive. -**Local-path anchor rule:** a `local_path` declared INSIDE another local -package is resolved relative to THAT package's own directory (npm/pip/cargo -parity). Sibling layouts that resolve outside the consuming project root -(e.g. `../sibling-pkg` from a local dep at the project edge) are -supported -- the consuming developer authored the manifest chain and -already trusts the layout. +**Local-path anchor rule:** a path declared inside another local package +resolves from that package's original source directory, including at user +scope (`--global`). Trusted sibling layouts outside the consumer project +root, such as `../sibling-pkg`, are supported. Direct user-scope local +dependencies must use absolute paths (`~/path` also works); a relative +reference without a known local parent's absolute source anchor is rejected. +Neither CWD nor `~/.apm/` substitutes for that anchor. Remote-cloned packages may declare a relative `path:` only when it resolves inside the same authenticated remote repo root. APM expands that path to the diff --git a/scripts/architecture_linter/checks/install_deployment_analyzers.py b/scripts/architecture_linter/checks/install_deployment_analyzers.py index d58844e0e7..a08b607da8 100644 --- a/scripts/architecture_linter/checks/install_deployment_analyzers.py +++ b/scripts/architecture_linter/checks/install_deployment_analyzers.py @@ -53,11 +53,13 @@ from scripts.architecture_linter.checks.install_policy_intent import EXTRA_RULES from scripts.architecture_linter.checks.install_request_and_source import ( _GUARD_INSTALL_SCOPE, + _GUARD_LOCAL_SCOPE, _GUARD_OUTCOME, _GUARD_PRIMITIVE_CLASSIFICATION, _GUARD_REQUEST_DEFAULTS, _GUARD_SOURCE_PLAN, check_install_scope_selection, + check_local_scope_admission, check_outcome, check_primitive_classification, check_request_defaults, @@ -132,6 +134,11 @@ "Direct MCP installs consume the install command's single scope decision.", check_install_scope_selection, ), + _rule( + _GUARD_LOCAL_SCOPE, + "Local USER-scope admission routes through user_scope_rejection_reason.", + check_local_scope_admission, + ), _rule( _GUARD_BASE_INTEGRATOR, "File-level deploy/sync/cleanup stays owned by BaseIntegrator.", diff --git a/scripts/architecture_linter/checks/install_request_and_source.py b/scripts/architecture_linter/checks/install_request_and_source.py index ffda5c991a..6b9d0cb84c 100644 --- a/scripts/architecture_linter/checks/install_request_and_source.py +++ b/scripts/architecture_linter/checks/install_request_and_source.py @@ -22,6 +22,7 @@ _duplicate_definition_lines, _facts_for, _lines, + _name_calls_in, _present, _present_re, _summary, @@ -45,6 +46,8 @@ _GUARD_INSTALL_SCOPE = "install-deployment-install-scope-selection" +_GUARD_LOCAL_SCOPE = "install-deployment-local-scope-admission" + _REQUEST_OWNER = "src/apm_cli/install/request.py" _MCP_COMMAND = "src/apm_cli/install/mcp/command.py" @@ -53,6 +56,49 @@ _ALLOWED_WRAPPER_DEFAULTS = frozenset({"update_refs", "verbose", "only_packages"}) +def check_local_scope_admission(provider: FactsProvider) -> tuple[Violation, ...]: + """Local scope admission must delegate to one owner with parent context.""" + owner_path = "src/apm_cli/install/package_resolution.py" + owner_name = "user_scope_rejection_reason" + owner, failures = _facts_for(provider, owner_path, _GUARD_LOCAL_SCOPE) + findings = list(failures) + if not failures and not _present(owner, f"def {owner_name}("): + findings.append(_summary(_GUARD_LOCAL_SCOPE, owner_path, "Missing local admission owner")) + consumers = ( + (_INSTALL_ADAPTER, "_resolve_package_references", False), + ("src/apm_cli/install/phases/resolve.py", "download_callback", True), + ("src/apm_cli/install/sources.py", "acquire", True), + ) + for path, function_name, needs_parent in consumers: + facts, failures = _facts_for(provider, path, _GUARD_LOCAL_SCOPE) + findings.extend(failures) + if failures: + continue + delegated = owner_name in _name_calls_in(facts, function_name) + if needs_parent: + index = provider.tree_index(path) + calls = ( + _named_calls(tuple(index.walk(index.root)), owner_name) + if index is not None and index.root is not None + else [] + ) + delegated = ( + delegated + and any(_has_name_keyword(call, "parent_pkg", "parent_pkg") for call in calls) + and not _present(facts, "scope is InstallScope.USER") + ) + if not delegated: + findings.append( + _summary( + _GUARD_LOCAL_SCOPE, + path, + "Local scope admission must call user_scope_rejection_reason " + "and retain declaring-parent context instead of an inline scope predicate", + ) + ) + return tuple(findings) + + def _wrapper_default_args(index: TreeIndex) -> list[str]: """Return trailing defaulted args of top-level ``_install_apm_dependencies``.""" if index.root is None: diff --git a/src/apm_cli/install/package_resolution.py b/src/apm_cli/install/package_resolution.py index 68ceda9851..44bde1a422 100644 --- a/src/apm_cli/install/package_resolution.py +++ b/src/apm_cli/install/package_resolution.py @@ -10,11 +10,14 @@ import builtins from collections.abc import Callable -from typing import Any +from typing import TYPE_CHECKING, Any from apm_cli.install.gitlab_resolver import _GITLAB_DIRECT_SHORTHAND_UNRESOLVED from apm_cli.utils.github_host import build_ssh_url +if TYPE_CHECKING: + from apm_cli.models.apm_package import APMPackage + GIT_PARENT_USER_SCOPE_ERROR = ( "git: parent dependencies are not supported at user scope. " "Use project scope or specify explicit git URL." @@ -110,12 +113,15 @@ def resolve_parsed_dependency_reference( return dep_ref, False -def user_scope_rejection_reason(dep_ref: Any, scope: Any) -> str | None: +def user_scope_rejection_reason( + dep_ref: Any, scope: Any, *, parent_pkg: APMPackage | None = None +) -> str | None: """Return a validation-fail reason if *dep_ref* is invalid at user scope. - Per #937, only relative local paths are rejected at user scope -- absolute - local paths are unambiguous and flow through the same _copy_local_package - code path as project scope. + Absolute local paths are unambiguous (#937). A relative transitive local + path also has an anchor when the resolver supplies its declaring local + package's original absolute source directory (#2815). Direct references + and unknown, remote, or unanchored parents retain the relative-path rejection. """ if scope is None: return None @@ -129,6 +135,18 @@ def user_scope_rejection_reason(dep_ref: Any, scope: Any) -> str | None: # which expanduser()s local paths before consuming them: `~/pkg` is # absolute after expansion and must NOT be rejected here. if not Path(local_path).expanduser().is_absolute(): + from apm_cli.deps.apm_resolver import APMDependencyResolver + + if ( + local_path + and dep_ref.declaring_parent + and parent_pkg is not None + and parent_pkg.source + and parent_pkg.source_path is not None + and parent_pkg.source_path.is_absolute() + and not APMDependencyResolver._is_remote_parent(parent_pkg) + ): + return None return ( "relative local paths are not supported at user scope (--global). " "Use an absolute path or a remote reference (owner/repo) instead" diff --git a/src/apm_cli/install/phases/resolve.py b/src/apm_cli/install/phases/resolve.py index fdb0735d35..f53717223c 100644 --- a/src/apm_cli/install/phases/resolve.py +++ b/src/apm_cli/install/phases/resolve.py @@ -363,7 +363,6 @@ def _resolve_dependencies( """Resolve dependencies and populate the resolution fields on ``ctx``.""" import threading as _threading - from apm_cli.core.scope import InstallScope from apm_cli.deps.apm_resolver import APMDependencyResolver from apm_cli.install.insecure_policy import ( _check_insecure_dependencies, @@ -371,6 +370,7 @@ def _resolve_dependencies( _guard_transitive_insecure_dependencies, _warn_insecure_dependencies, ) + from apm_cli.install.package_resolution import user_scope_rejection_reason from apm_cli.install.phases.local_content import _copy_local_package # 3b. Dedicated registry resolver (design §3.1, §8) @@ -553,15 +553,7 @@ def download_callback(dep_ref, modules_dir, parent_chain="", parent_pkg=None): # Handle local packages: copy instead of git clone if dep_ref.is_local and dep_ref.local_path: - if ( - scope is InstallScope.USER - and not Path(dep_ref.local_path).expanduser().is_absolute() - ): - # At user scope, relative local paths have no meaningful - # root (cwd is arbitrary, $HOME is not a project). Only - # absolute paths are unambiguous; reject relative refs. - # Note: callback_failures is a set (see line ~105), - # so use .add() rather than dict-style assignment. + if user_scope_rejection_reason(dep_ref, scope, parent_pkg=parent_pkg): with callback_lock: callback_failures.add(dep_ref.get_unique_key()) _tui = getattr(ctx, "tui", None) diff --git a/src/apm_cli/install/sources.py b/src/apm_cli/install/sources.py index e5743a5e23..3ba74381fa 100644 --- a/src/apm_cli/install/sources.py +++ b/src/apm_cli/install/sources.py @@ -118,9 +118,9 @@ def acquire(self) -> Materialization | None: from apm_cli.agent_plugins.errors import AgentPluginError from apm_cli.bundle.local_bundle import route_agent_plugin_package from apm_cli.constants import APM_YML_FILENAME - from apm_cli.core.scope import InstallScope from apm_cli.deps._shared import materialize_marketplace_manifest from apm_cli.deps.installed_package import InstalledPackage + from apm_cli.install.package_resolution import user_scope_rejection_reason from apm_cli.install.phases.local_content import _copy_local_package from apm_cli.models.apm_package import ( APMPackage, @@ -139,25 +139,24 @@ def acquire(self) -> Materialization | None: diagnostics = ctx.diagnostics logger = ctx.logger - # User scope: relative paths are project-relative and have no - # meaningful root outside a project, so reject them. Absolute - # paths are unambiguous and supported. - if ctx.scope is InstallScope.USER: + parent_pkg = None + if dep_ref.declaring_parent: + node = ctx.dependency_graph.dependency_tree.get_node(dep_key) + if node is not None and node.parent is not None: + parent_pkg = node.parent.package + scope_reject = user_scope_rejection_reason(dep_ref, ctx.scope, parent_pkg=parent_pkg) + if scope_reject: local_path_str = dep_ref.local_path or "" - if not local_path_str or not Path(local_path_str).expanduser().is_absolute(): - diagnostics.warn( - f"Skipped local package '{local_path_str}' " - "-- relative local paths are not supported at user scope " - "(--global). Use an absolute path or a remote reference " - "(owner/repo) instead.", - package=local_path_str, + diagnostics.warn( + f"Skipped local package '{local_path_str}' -- {scope_reject}.", + package=local_path_str, + ) + if logger: + logger.verbose_detail( + f" Skipping {local_path_str} (relative local paths " + "are project-relative and have no root at user scope)" ) - if logger: - logger.verbose_detail( - f" Skipping {local_path_str} (relative local paths " - "are project-relative and have no root at user scope)" - ) - return None + return None # Determine the anchor for relative ``local_path`` (#857). For # direct deps from the root project this is ``ctx.source_root`` diff --git a/tests/integration/test_architecture_owner_rule_mutations.py b/tests/integration/test_architecture_owner_rule_mutations.py index 5c5a0424c4..88cb668453 100644 --- a/tests/integration/test_architecture_owner_rule_mutations.py +++ b/tests/integration/test_architecture_owner_rule_mutations.py @@ -317,6 +317,14 @@ class MutationCase: new="def set(", intent="Config mutation stops routing through the canonical lifecycle lock.", ), + MutationCase( + guard_id="install-deployment-local-scope-admission", + rule_id="install-deployment-local-scope-admission", + path="src/apm_cli/install/phases/resolve.py", + old="user_scope_rejection_reason(dep_ref, scope, parent_pkg=parent_pkg)", + new="user_scope_rejection_reason(dep_ref, scope, parent_pkg=None)", + intent="Resolution drops the declaring local parent's source context from admission.", + ), MutationCase( guard_id="install-deployment-lsp-lifecycle", rule_id="install-deployment-lsp-lifecycle", diff --git a/tests/integration/test_global_local_transitive_dependency_e2e.py b/tests/integration/test_global_local_transitive_dependency_e2e.py new file mode 100644 index 0000000000..780622fe56 --- /dev/null +++ b/tests/integration/test_global_local_transitive_dependency_e2e.py @@ -0,0 +1,123 @@ +"""Installed-CLI regressions for local declaring-parent anchors at user scope.""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from apm_cli.utils.yaml_io import dump_yaml, load_yaml +from tests.utils.apm_lifecycle_runner import ApmLifecycleRunner +from tests.utils.isolated_apm_environment import IsolatedApmEnvironment +from tests.utils.local_package import LocalPackageFactory + +pytestmark = [ + pytest.mark.e2e, + pytest.mark.requires_apm_binary, + pytest.mark.lifecycle_smoke, + pytest.mark.lifecycle_merge_group, +] + + +@pytest.mark.parametrize( + ("user_scope", "relative_child"), + [(False, True), (True, False), (True, True)], + ids=["project-relative-control", "user-absolute-control", "user-relative-regression"], +) +def test_local_transitive_scope_parity( + tmp_path: Path, + apm_binary_path: Path, + user_scope: bool, + relative_child: bool, +) -> None: + """Success must include both declared packages, regardless of deploy scope.""" + environment = IsolatedApmEnvironment.create(tmp_path / "scenario", base_env=os.environ) + factory = LocalPackageFactory(environment.package_root) + child = factory.create("child", targets=["cursor"]) + child_reference = "../child" if relative_child else child.root.as_posix() + parent = factory.create( + "parent", + dependencies=[{"path": child_reference}], + targets=["cursor"], + ) + sources = { + package.name: factory.add_command( + package, + package.name, + f"---\ndescription: {package.name} command\n---\n# Command\n" + f"Distinct {package.name} command body.\n", + ) + for package in (parent, child) + } + assert (parent.root / "../child").resolve() == child.root + assert child.manifest_path.is_file() + consumer = environment.work_root / "consumer" + consumer.mkdir() + manifest_root = environment.config_root if user_scope else consumer + manifest = { + "name": "consumer", + "version": "0.1.0", + "targets": ["cursor"], + "dependencies": {"apm": [{"path": parent.root.as_posix()}]}, + } + dump_yaml(manifest, manifest_root / "apm.yml") + deploy_root = environment.home if user_scope else consumer + runner = ApmLifecycleRunner([str(apm_binary_path)]) + args = ("install", "--target", "cursor", "--no-policy", "--parallel-downloads", "0") + if user_scope: + args += ("--global",) + + for iteration in range(2): + result = runner.run( + args, + scenario_id=f"local-transitive-scope-parity-{iteration}", + cwd=consumer, + env=environment.subprocess_env(), + ) + evidence = f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + assert result.returncode == 0, evidence + lockfile = load_yaml(manifest_root / "apm.lock.yaml") + entries = lockfile["dependencies"] + assert len(entries) == 2, evidence + by_repo = {entry["repo_url"]: entry for entry in entries} + assert set(by_repo) == {"_local/parent", "_local/child"}, evidence + locked_child = by_repo["_local/child"] + assert locked_child["local_path"] == child_reference + assert locked_child["source"] == "local" + assert locked_child["depth"] == 2 + assert locked_child["resolved_by"] == "_local/parent" + assert locked_child["declaring_parent"] == parent.root.as_posix() + assert locked_child["anchored_local_path"] == child.root.as_posix() + assert load_yaml(manifest_root / "apm.yml") == manifest + + commands = list((deploy_root / ".cursor" / "commands").glob("*.md")) + assert len(commands) == 2, evidence + command_bodies = [path.read_text(encoding="utf-8") for path in commands] + for name, source in sources.items(): + assert sum(f"Distinct {name} command body." in body for body in command_bodies) == 1 + copies = list((manifest_root / "apm_modules").rglob(f"{name}.prompt.md")) + assert len(copies) == 1 + assert copies[0].read_bytes() == source.read_bytes() + if user_scope: + assert not (consumer / ".cursor").exists() + assert not (consumer / "apm_modules").exists() + assert not (consumer / "apm.lock.yaml").exists() + + +def test_direct_user_relative_input_remains_rejected(tmp_path: Path, apm_binary_path: Path) -> None: + """A real local directory under CWD does not make a direct global ref valid.""" + environment = IsolatedApmEnvironment.create(tmp_path / "scenario", base_env=os.environ) + factory = LocalPackageFactory(environment.work_root) + factory.create("direct", targets=["cursor"]) + result = ApmLifecycleRunner([str(apm_binary_path)]).run( + ("install", "./direct", "--global", "--target", "cursor", "--no-policy"), + cwd=environment.work_root, + env=environment.subprocess_env(), + ) + assert result.returncode != 0 + output = result.stdout + result.stderr + assert "relative local paths" in output + assert "absolute path" in output + assert not (environment.config_root / "apm.lock.yaml").exists() + assert not (environment.home / ".cursor" / "commands").exists() diff --git a/tests/unit/install/test_local_scope_admission.py b/tests/unit/install/test_local_scope_admission.py new file mode 100644 index 0000000000..fe36098523 --- /dev/null +++ b/tests/unit/install/test_local_scope_admission.py @@ -0,0 +1,71 @@ +"""Real resolver and materialization boundary coverage for issue #2815.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from apm_cli.core.scope import InstallScope +from apm_cli.deps.tiered_ref_resolver import RefFreshnessPolicy +from apm_cli.install.context import InstallContext +from apm_cli.install.phases.resolve import _materialization, _resolve_dependencies +from apm_cli.install.resolution_staging import ResolutionStagingSession +from apm_cli.install.sources import LocalDependencySource +from apm_cli.models.apm_package import APMPackage +from apm_cli.utils.diagnostics import DiagnosticCollector +from tests.utils.local_package import LocalPackageFactory + +pytestmark = pytest.mark.component + + +@pytest.mark.parametrize("boundary", ["resolve", "acquire"]) +def test_user_scope_preserves_local_parent_anchor(tmp_path: Path, boundary: str) -> None: + """Each production boundary must admit the same anchored transitive child.""" + factory = LocalPackageFactory(tmp_path / "packages") + child = factory.create("child", targets=["cursor"]) + parent = factory.create("parent", dependencies=[{"path": "../child"}], targets=["cursor"]) + consumer = factory.create("consumer", dependencies=[{"path": parent.root.as_posix()}]) + package = APMPackage.from_apm_yml(consumer.manifest_path, source_path=consumer.root) + modules = tmp_path / "user" / ".apm" / "apm_modules" + modules.mkdir(parents=True) + ctx = InstallContext( + project_root=consumer.root, + apm_dir=consumer.root, + apm_package=package, + # Isolate acquire from the resolver's admission decision. + scope=InstallScope.PROJECT if boundary == "acquire" else InstallScope.USER, + all_apm_deps=package.get_apm_dependencies(), + apm_modules_dir=modules, + ref_freshness_policy=RefFreshnessPolicy.REPRODUCIBLE, + downloader=MagicMock(shared_clone_cache=None), + diagnostics=DiagnosticCollector(), + ) + staging = ResolutionStagingSession(modules) + try: + _resolve_dependencies(ctx, staging, _materialization.CachedMaterializationPathReader()) + child_ref = next(dep for dep in ctx.deps_to_install if dep.repo_url == "_local/child") + if boundary == "resolve": + assert not ctx.callback_failures + assert child_ref.get_unique_key() in ctx.callback_downloaded + node = ctx.dependency_graph.dependency_tree.get_node(child_ref.get_unique_key()) + assert node.package.source_path == child.root + else: + ctx.scope = InstallScope.USER + source = LocalDependencySource( + ctx, child_ref, child_ref.get_install_path(modules), child_ref.get_unique_key() + ) + materialized = source.acquire() + assert materialized is not None + assert materialized.package_info.package.source_path == child.root + assert ctx.installed_packages[0].resolved_by == "_local/parent" + assert ctx.dep_base_dirs[child_ref.get_unique_key()] == parent.root + assert child_ref.local_path == "../child" + assert child_ref.declaring_parent == parent.root.as_posix() + assert child_ref.get_install_path(modules).joinpath("apm.yml").read_bytes() == ( + child.manifest_path.read_bytes() + ) + ctx.downloader.download_package.assert_not_called() + finally: + staging.rollback() diff --git a/tests/unit/install/test_user_scope_rejection_reason.py b/tests/unit/install/test_user_scope_rejection_reason.py index 26790d10a7..c12f61f850 100644 --- a/tests/unit/install/test_user_scope_rejection_reason.py +++ b/tests/unit/install/test_user_scope_rejection_reason.py @@ -6,9 +6,11 @@ * User scope, remote ref -> NEVER reject (the happy path). * User scope, local *abs* -> NEVER reject (an absolute path is unambiguous; see PR #937 commit message). - * User scope, local *rel* -> ALWAYS reject (relative-to-cwd is ambiguous + * User scope, direct *rel* -> ALWAYS reject (relative-to-cwd is ambiguous outside a project; ``$HOME`` is not a project root). + * User scope, local child -> accept only with resolver-proven local parent + provenance and an absolute source anchor. * User scope, ``git: parent`` inheritance -> ALWAYS reject (no monorepo root at user scope). @@ -24,6 +26,7 @@ from __future__ import annotations import os +from pathlib import Path import pytest @@ -32,6 +35,7 @@ GIT_PARENT_USER_SCOPE_ERROR, user_scope_rejection_reason, ) +from apm_cli.models.apm_package import APMPackage from apm_cli.models.dependency.reference import DependencyReference @@ -147,6 +151,59 @@ def test_user_scope_handles_empty_local_path_defensively(): assert "relative" in reason.lower() +@pytest.mark.parametrize("local_path", ["../child", "./nested/child"]) +def test_user_scope_accepts_proven_local_parent_anchor(tmp_path: Path, local_path: str) -> None: + """A resolver-proven local child is anchored, not relative to arbitrary CWD.""" + parent = APMPackage( + name="parent", version="1.0.0", source="_local/parent", source_path=tmp_path + ) + child = _local_ref(local_path) + child.declaring_parent = tmp_path.as_posix() + + assert user_scope_rejection_reason(child, InstallScope.USER, parent_pkg=parent) is None + + +@pytest.mark.parametrize( + ("source", "anchor", "declared", "local_path"), + [ + ("_local/parent", "absolute", False, "../child"), + ("_local/parent", None, True, "../child"), + ("_local/parent", "relative", True, "../child"), + (None, "absolute", True, "../child"), + ("org/remote", "absolute", True, "../child"), + ("https://example.invalid/remote", "absolute", True, "../child"), + ("_local/parent", "absolute", True, ""), + ], + ids=[ + "direct-ref", + "missing-anchor", + "relative-anchor", + "unknown-provenance", + "remote-shorthand", + "remote-url", + "empty-path", + ], +) +def test_user_scope_does_not_infer_local_parent_trust( + tmp_path: Path, + source: str | None, + anchor: str | None, + declared: bool, + local_path: str, +) -> None: + """A root fallback or a remote staged directory cannot authorize local reads.""" + source_path = tmp_path if anchor == "absolute" else Path("parent") if anchor else None + parent = APMPackage(name="parent", version="1.0.0", source=source, source_path=source_path) + child = _local_ref(local_path) + if declared: + child.declaring_parent = tmp_path.as_posix() + + reason = user_scope_rejection_reason(child, InstallScope.USER, parent_pkg=parent) + + assert reason is not None + assert "absolute path" in reason + + # --------------------------------------------------------------------------- # User-scope remote / parent-inheritance handling # --------------------------------------------------------------------------- From e7188114c89824b962dc5b6ebf9cf07ceb7b92d8 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sat, 5 Sep 2026 10:56:22 +0200 Subject: [PATCH 02/34] test(architecture): register local scope admission in fixed inventory Keep the exact semantic inventory contract in sync with the new #2815 admission guard. Reproduces and fixes the Linux CI shard failure without weakening the inventory assertion. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/unit/scripts/test_architecture_runner.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/scripts/test_architecture_runner.py b/tests/unit/scripts/test_architecture_runner.py index 13b743a855..ebb4fe6f02 100644 --- a/tests/unit/scripts/test_architecture_runner.py +++ b/tests/unit/scripts/test_architecture_runner.py @@ -629,6 +629,7 @@ def exiting_import( install-deployment-install-scope-selection install-deployment-local-bundle-policy-preflight install-deployment-local-identity-anchor +install-deployment-local-scope-admission install-deployment-locked-skill-subset-reconstruction install-deployment-lsp-lifecycle install-deployment-lsp-target-contract From c2f20a0e167a21eda4fb53c685807ec5804e3472 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sun, 6 Sep 2026 12:45:55 +0200 Subject: [PATCH 03/34] spec: reconcile trusted-local source anchoring (#2818) Replace the waived req-mf-016 claim with explicit source anchors and separate remote/internal-symlink containment rules. Keep restricted-root consumer policies permitted and record the single-amendment public-comment exception without changing Section 9. Add executable conformance controls and forced rematerialization from the written local lock. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 4 + CONFORMANCE.json | 47 ++- CONFORMANCE.md | 7 +- .../manifests/openapm-v0.1.requirements.yml | 2 + .../src/content/docs/reference/cli/install.md | 4 + docs/src/content/docs/specs/openapm-v0.1.md | 118 ++++++- .../.apm/skills/apm-usage/dependencies.md | 10 + .../manifest/valid-local-parent.yml | 9 + ..._global_local_transitive_dependency_e2e.py | 14 +- .../spec_conformance/test_local_path_reqs.py | 330 ++++++++++++++++++ tests/spec_conformance/test_manifest_reqs.py | 13 - 11 files changed, 526 insertions(+), 32 deletions(-) create mode 100644 tests/fixtures/spec-conformance/manifest/valid-local-parent.yml create mode 100644 tests/spec_conformance/test_local_path_reqs.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a6cd78c73..2d2ef6eefa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Global installs retain local relative children using their declaring source; `docs/src/content/docs/specs/openapm-v0.1.md` reconciles trusted-local anchoring with remote and internal-symlink containment, backed by executable conformance cases. (#2820) + ## [0.29.1] - 2026-09-06 ### Security diff --git a/CONFORMANCE.json b/CONFORMANCE.json index d74e8366eb..f0ac331f0b 100644 --- a/CONFORMANCE.json +++ b/CONFORMANCE.json @@ -479,16 +479,45 @@ }, { "conformance_class": "consumer", + "fixture": "tests/fixtures/spec-conformance/manifest/valid-local-parent.yml", "id": "req-mf-016", "keyword": "MUST", "section": "4.3.5", - "status": "skipped", - "test_count": 1, - "tests": [ - "tests/spec_conformance/test_manifest_reqs.py::test_consumer_rejects_absolute_paths_in_apm_source" - ], - "waivers": [ - "Path-shape negative test requires apm_cli's path-policy loader to be invokable from the test harness; the JSON Schema currently models `path` as a free-form string. Tracked as a follow-up: tighten the schema to forbid leading `/` and document the absolute-path rejection in the schema additionalProperties." + "status": "active", + "test_count": 32, + "tests": [ + "tests/spec_conformance/test_local_path_reqs.py::test_direct_local_source_is_anchored_before_copy[project-relative]", + "tests/spec_conformance/test_local_path_reqs.py::test_direct_local_source_is_anchored_before_copy[user-absolute]", + "tests/spec_conformance/test_local_path_reqs.py::test_direct_local_source_is_anchored_before_copy[user-home]", + "tests/spec_conformance/test_local_path_reqs.py::test_local_package_rejects_file_symlink_cycle", + "tests/spec_conformance/test_local_path_reqs.py::test_local_package_rejects_uncontained_or_unresolvable_symlink[broken]", + "tests/spec_conformance/test_local_path_reqs.py::test_local_package_rejects_uncontained_or_unresolvable_symlink[directory-cycle]", + "tests/spec_conformance/test_local_path_reqs.py::test_local_package_rejects_uncontained_or_unresolvable_symlink[outside-directory]", + "tests/spec_conformance/test_local_path_reqs.py::test_local_package_rejects_uncontained_or_unresolvable_symlink[outside-file]", + "tests/spec_conformance/test_local_path_reqs.py::test_local_path_prefixes_are_recognized[../child]", + "tests/spec_conformance/test_local_path_reqs.py::test_local_path_prefixes_are_recognized[..\\\\child]", + "tests/spec_conformance/test_local_path_reqs.py::test_local_path_prefixes_are_recognized[./child]", + "tests/spec_conformance/test_local_path_reqs.py::test_local_path_prefixes_are_recognized[.\\\\child]", + "tests/spec_conformance/test_local_path_reqs.py::test_local_path_prefixes_are_recognized[/child]", + "tests/spec_conformance/test_local_path_reqs.py::test_local_path_prefixes_are_recognized[~/child]", + "tests/spec_conformance/test_local_path_reqs.py::test_local_path_prefixes_are_recognized[~\\\\child]", + "tests/spec_conformance/test_local_path_reqs.py::test_local_sibling_uses_original_source_in_each_scope[InstallScope.PROJECT]", + "tests/spec_conformance/test_local_path_reqs.py::test_local_sibling_uses_original_source_in_each_scope[InstallScope.USER]", + "tests/spec_conformance/test_local_path_reqs.py::test_remote_parent_cannot_expand_into_host_files[absolute-inside]", + "tests/spec_conformance/test_local_path_reqs.py::test_remote_parent_cannot_expand_into_host_files[absolute]", + "tests/spec_conformance/test_local_path_reqs.py::test_remote_parent_cannot_expand_into_host_files[escape]", + "tests/spec_conformance/test_local_path_reqs.py::test_remote_parent_cannot_expand_into_host_files[home]", + "tests/spec_conformance/test_local_path_reqs.py::test_remote_parent_cannot_expand_into_host_files[symlink]", + "tests/spec_conformance/test_local_path_reqs.py::test_remote_parent_cannot_expand_into_host_files[windows-absolute]", + "tests/spec_conformance/test_local_path_reqs.py::test_remote_relative_child_retains_repository_and_ref[../child]", + "tests/spec_conformance/test_local_path_reqs.py::test_remote_relative_child_retains_repository_and_ref[..\\\\child]", + "tests/spec_conformance/test_local_path_reqs.py::test_selected_local_root_allows_only_internal_symlink_content[resolved-source-alias]", + "tests/spec_conformance/test_local_path_reqs.py::test_selected_local_root_allows_only_internal_symlink_content[source-directory]", + "tests/spec_conformance/test_local_path_reqs.py::test_user_relative_admission_requires_proven_local_parent[direct]", + "tests/spec_conformance/test_local_path_reqs.py::test_user_relative_admission_requires_proven_local_parent[missing-parent]", + "tests/spec_conformance/test_local_path_reqs.py::test_user_relative_admission_requires_proven_local_parent[missing-source]", + "tests/spec_conformance/test_local_path_reqs.py::test_user_relative_admission_requires_proven_local_parent[relative-source]", + "tests/spec_conformance/test_local_path_reqs.py::test_user_relative_admission_requires_proven_local_parent[remote]" ] }, { @@ -1412,8 +1441,8 @@ "spec_version": "v0.1.1", "summary_by_class": { "consumer": { - "active": 90, - "skipped": 1, + "active": 91, + "skipped": 0, "unbound": 0, "xfail": 0 }, diff --git a/CONFORMANCE.md b/CONFORMANCE.md index 510023b595..840756bde9 100644 --- a/CONFORMANCE.md +++ b/CONFORMANCE.md @@ -25,7 +25,7 @@ All four conformance classes (Producer, Consumer, Registry, Governance) carry ac | Class | Active | Skipped | Xfail | Unbound | |-------|-------:|--------:|------:|--------:| | Producer | 12 | 0 | 0 | 0 | -| Consumer | 90 | 1 | 0 | 0 | +| Consumer | 91 | 0 | 0 | 0 | | Registry | 1 | 0 | 0 | 0 | | Governance | 17 | 0 | 0 | 0 | @@ -74,7 +74,7 @@ All four conformance classes (Producer, Consumer, Registry, Governance) carry ac | [req-mf-013](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-013) | MUST | 4.5 | consumer | active | 1 | - | | [req-mf-014](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-014) | MUST | 4.2.3 | producer | active | 1 | - | | [req-mf-015](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-015) | MUST | 4.2.3 | producer | active | 1 | - | -| [req-mf-016](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-016) | MUST | 4.3.5 | consumer | skipped | 1 | - | +| [req-mf-016](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-016) | MUST | 4.3.5 | consumer | active | 32 | - | | [req-mf-017](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-017) | MUST | 4.7 | producer | active | 1 | - | | [req-mf-018](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-018) | MUST | 4.6.1 | consumer | active | 1 | - | | [req-mf-019](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-019) | MUST | 4.2.4 | consumer | active | 1 | - | @@ -163,6 +163,3 @@ All four conformance classes (Producer, Consumer, Registry, Governance) carry ac ### req-lk-018 - Publish-timestamp recording is a publisher-side SHOULD that requires registry interaction to exercise end-to-end. The schema affordance (generated_at) is asserted above; full publisher coverage requires the registry wire conformance module which is not in v0.1 scope. -### req-mf-016 -- Path-shape negative test requires apm_cli's path-policy loader to be invokable from the test harness; the JSON Schema currently models `path` as a free-form string. Tracked as a follow-up: tighten the schema to forbid leading `/` and document the absolute-path rejection in the schema additionalProperties. - diff --git a/docs/public/specs/manifests/openapm-v0.1.requirements.yml b/docs/public/specs/manifests/openapm-v0.1.requirements.yml index 8e27cf8939..9d6e92b032 100644 --- a/docs/public/specs/manifests/openapm-v0.1.requirements.yml +++ b/docs/public/specs/manifests/openapm-v0.1.requirements.yml @@ -87,6 +87,8 @@ requirements: keyword: MUST section: "4.3.5" conformance_class: consumer + fixture: tests/fixtures/spec-conformance/manifest/valid-local-parent.yml + notes: "Source anchoring and scope admission are distinct from remote-repository and internal local-symlink containment; executable positive and negative cases replace the absolute-path waiver (#2818)." - id: req-mf-017 keyword: MUST section: "4.7" diff --git a/docs/src/content/docs/reference/cli/install.md b/docs/src/content/docs/reference/cli/install.md index 9f688d39a0..87519899c2 100644 --- a/docs/src/content/docs/reference/cli/install.md +++ b/docs/src/content/docs/reference/cli/install.md @@ -24,6 +24,10 @@ also works). A local package can still declare a relative child such as `../child`: APM resolves it from that declaring package's original source directory, not the current working directory or `~/.apm/`. Direct or unanchored relative local references remain unsupported at user scope. +This source anchor does not change deployment scope. APM does not look in +another scope's installed packages for a missing local source. Remote-declared +relative paths stay inside their authenticated repository; symlinks within a +selected local package must stay inside that package's resolved source directory. :::caution `http://` dependencies are refused unless you pass `--allow-insecure` (direct) or `--allow-insecure-host HOSTNAME` (transitive). diff --git a/docs/src/content/docs/specs/openapm-v0.1.md b/docs/src/content/docs/specs/openapm-v0.1.md index c1e6e616bb..392cc05fb0 100644 --- a/docs/src/content/docs/specs/openapm-v0.1.md +++ b/docs/src/content/docs/specs/openapm-v0.1.md @@ -221,6 +221,8 @@ type"), the definition section is cross-linked. | **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). | @@ -621,10 +623,69 @@ selection is configured per project. **[req-mf-016]** A conforming **consumer** implementation MUST recognise dependency strings beginning with `./`, `../`, `/`, `~/`, -`.\`, `..\`, or `~\` as local-path entries. The resolver MUST refuse -any local-path entry whose normalised form contains `..` segments -that would escape the project root, with a diagnostic naming the -offending path. +`.\`, `..\`, or `~\` as local-path entries. Admission and resolution +depend on the declaring source, not merely on the presence of `..`: + +(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) **User-scope admission.** A direct relative local dependency at +user scope MUST be rejected. A relative transitive local dependency +at user scope MUST be rejected unless the consumer has established +its declaring local parent and that parent's original absolute +source directory. A local-looking path or a recorded path string +alone is not proof that its parent is local. 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. #### 4.3.6 MCP dependencies @@ -3841,10 +3902,59 @@ renumbering of conformance classes. **Total normative statements: 121** (116 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 +### Proposed local-path correction (2026-09-06) + +Amendment identifier **0.1.40 (proposed)** tracks the focused Mode C +stale-spec correction in +[microsoft/apm#2818](https://github.com/microsoft/apm/issues/2818), +drafted in [microsoft/apm#2820](https://github.com/microsoft/apm/pull/2820). +It replaces [req-mf-016](#req-mf-016)'s blanket consumer-root escape +rejection with declaring-source anchoring, trusted local sibling +admission, remote-repository containment, and internal local-symlink +containment. It adds executable positive and negative conformance +cases in place of the skipped absolute-path-rejection claim. +Statement count remains **121** (116 MUST, 5 SHOULD); no requirement +identifier is added, removed, or renumbered, and no file schema changes. + +**Classification and compatibility.** This is a substantive normative +correction, not typo errata. It removes a false universal +consumer-root containment claim without requiring every consumer to +admit every local source root. A consumer that restricts local roots +may retain that restriction as documented policy, with honest +rejection diagnostics; a consumer that admits a reference is bound +to the specified source anchor and containment rules. Existing +trusted-local sibling consumers retain their source-selection +behavior. Implementers should distinguish permitted source roots +from the required anchoring and containment of admitted sources. +The explicit remote and internal-symlink obligations also need +compatibility review; this amendment does not assume that every +previously conforming implementation already enforced them. The proposed +amendment identifier does not itself decide whether the compatibility +impact requires a new minor revision and migration window under +[Section 9.2](#92-breaking-vs-non-breaking-change-definition), +[Section 9.4](#94-errata-vs-new-revision), and +[Section 9.5](#95-migration-windows-for-consumers). That classification +remains part of the non-author review and maintainer ratification. +No versioning or migration exception is claimed. + +**One-amendment process exception.** On 2026-09-06 the maintainer +authorized this local-path correction without the 14-day public +comment period in Section 9.3 step 4. The exception applies only to +this amendment in #2820. The required non-author review, conformance +evidence, and ratification remain; Section 9 and the process for +future amendments are unchanged. This draft does not record panel +approval or a completed ratification. + | Version | Date | Changes | |---------|------------|----------------------------------------------------------| | 0.1 | 2026-05-10 | Initial editor's Working Draft. | diff --git a/packages/apm-guide/.apm/skills/apm-usage/dependencies.md b/packages/apm-guide/.apm/skills/apm-usage/dependencies.md index f1ae28b73b..9bfa17fee2 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/dependencies.md +++ b/packages/apm-guide/.apm/skills/apm-usage/dependencies.md @@ -59,6 +59,9 @@ root, such as `../sibling-pkg`, are supported. Direct user-scope local dependencies must use absolute paths (`~/path` also works); a relative reference without a known local parent's absolute source anchor is rejected. Neither CWD nor `~/.apm/` substitutes for that anchor. +APM does not search another installation scope's installed packages to resolve +a missing local source. Local source selection is a trust decision, not a +guarantee that the package content is safe. Remote-cloned packages may declare a relative `path:` only when it resolves inside the same authenticated remote repo root. APM expands that path to the @@ -66,6 +69,13 @@ parent's remote host/repo/ref and fetches the sibling from the same origin. Absolute paths, paths that escape the repo root, and cross-repo local paths are rejected. +After a local source directory is selected and resolved, symlinks inside that +package must resolve within that same directory. Internal links are copied as +content; broken, cyclic, or escaping links fail materialization. Selecting a +source-directory path that itself resolves through a symlink is a separate +operation. These checks do not make an entire install atomic or protect against +all concurrent filesystem changes. + **GitLab `path:` fetch transport:** GitLab `path:` files are fetched over git transport, not the REST API, so self-hosted instances with the API disabled still install. Path containment is enforced on the materialized file to reject diff --git a/tests/fixtures/spec-conformance/manifest/valid-local-parent.yml b/tests/fixtures/spec-conformance/manifest/valid-local-parent.yml new file mode 100644 index 0000000000..ee8cca959e --- /dev/null +++ b/tests/fixtures/spec-conformance/manifest/valid-local-parent.yml @@ -0,0 +1,9 @@ +# req-mf-016: this selected local parent and its child are siblings outside +# the consumer's project/user root; source anchoring is not deployment scope. +name: parent +version: 1.0.0 +targets: + - cursor +dependencies: + apm: + - path: ../child diff --git a/tests/integration/test_global_local_transitive_dependency_e2e.py b/tests/integration/test_global_local_transitive_dependency_e2e.py index 780622fe56..bfbbe9677a 100644 --- a/tests/integration/test_global_local_transitive_dependency_e2e.py +++ b/tests/integration/test_global_local_transitive_dependency_e2e.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import shutil from pathlib import Path import pytest @@ -68,7 +69,14 @@ def test_local_transitive_scope_parity( if user_scope: args += ("--global",) - for iteration in range(2): + lock_bytes = None + for iteration in range(3): + if iteration == 2: + # Keep the written lock and source roots; force actual rematerialization. + modules = manifest_root / "apm_modules" + assert modules.is_dir() and not modules.is_symlink() + assert modules.is_relative_to(environment.root) + shutil.rmtree(modules) result = runner.run( args, scenario_id=f"local-transitive-scope-parity-{iteration}", @@ -78,6 +86,10 @@ def test_local_transitive_scope_parity( evidence = f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" assert result.returncode == 0, evidence lockfile = load_yaml(manifest_root / "apm.lock.yaml") + current_lock_bytes = (manifest_root / "apm.lock.yaml").read_bytes() + if lock_bytes is not None: + assert current_lock_bytes == lock_bytes + lock_bytes = current_lock_bytes entries = lockfile["dependencies"] assert len(entries) == 2, evidence by_repo = {entry["repo_url"]: entry for entry in entries} diff --git a/tests/spec_conformance/test_local_path_reqs.py b/tests/spec_conformance/test_local_path_reqs.py new file mode 100644 index 0000000000..99a70afa99 --- /dev/null +++ b/tests/spec_conformance/test_local_path_reqs.py @@ -0,0 +1,330 @@ +"""Executable req-mf-016 source anchoring, admission, and containment contracts.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from apm_cli.core.scope import InstallScope +from apm_cli.deps.apm_resolver import APMDependencyResolver +from apm_cli.deps.tiered_ref_resolver import RefFreshnessPolicy +from apm_cli.install.context import InstallContext +from apm_cli.install.package_resolution import user_scope_rejection_reason +from apm_cli.install.phases.local_content import _copy_local_package +from apm_cli.install.phases.resolve import _materialization, _resolve_dependencies +from apm_cli.install.resolution_staging import ResolutionStagingSession +from apm_cli.install.sources import LocalDependencySource +from apm_cli.models.apm_package import APMPackage +from apm_cli.models.dependency import DependencyReference +from apm_cli.utils.diagnostics import DiagnosticCollector +from apm_cli.utils.path_security import PathTraversalError +from apm_cli.utils.yaml_io import dump_yaml +from tests.spec_conformance._helpers import load_yaml_fixture +from tests.utils.local_package import LocalPackageFactory + +pytestmark = pytest.mark.component + + +@pytest.mark.req("req-mf-016") +@pytest.mark.parametrize( + "reference", ["./child", "../child", "/child", "~/child", ".\\child", "..\\child", "~\\child"] +) +def test_local_path_prefixes_are_recognized(reference: str) -> None: + """Recognize local spelling without confusing it with a remote coordinate.""" + dep = DependencyReference.parse(reference) + assert dep.is_local + assert dep.local_path == reference + + +@pytest.mark.req("req-mf-016") +@pytest.mark.parametrize("scope", [InstallScope.PROJECT, InstallScope.USER]) +def test_local_sibling_uses_original_source_in_each_scope( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, scope: InstallScope +) -> None: + """Both real admission consumers retain the original parent, not CWD/staging.""" + factory = LocalPackageFactory(tmp_path / "sources") + child = factory.create("child", targets=["cursor"]) + source = factory.add_command(child, "child", "---\ndescription: child\n---\nOriginal child\n") + parent = factory.create("parent") + dump_yaml(load_yaml_fixture("manifest", "valid-local-parent.yml"), parent.manifest_path) + consumer = LocalPackageFactory(tmp_path / "scope").create( + "consumer", dependencies=[{"path": parent.root.as_posix()}] + ) + unrelated = tmp_path / "unrelated" + decoy = LocalPackageFactory(unrelated).create("child") + (unrelated / "cwd").mkdir() + monkeypatch.chdir(unrelated / "cwd") + package = APMPackage.from_apm_yml(consumer.manifest_path, source_path=consumer.root) + modules = consumer.root / "apm_modules" + modules.mkdir() + ctx = InstallContext( + project_root=consumer.root, + apm_dir=consumer.root, + apm_package=package, + scope=scope, + all_apm_deps=package.get_apm_dependencies(), + apm_modules_dir=modules, + ref_freshness_policy=RefFreshnessPolicy.REPRODUCIBLE, + downloader=MagicMock(shared_clone_cache=None), + diagnostics=DiagnosticCollector(), + ) + staging = ResolutionStagingSession(modules) + try: + _resolve_dependencies(ctx, staging, _materialization.CachedMaterializationPathReader()) + assert not ctx.callback_failures + assert {dep.repo_url for dep in ctx.deps_to_install} == {"_local/parent", "_local/child"} + child_ref = next(dep for dep in ctx.deps_to_install if dep.repo_url == "_local/child") + node = ctx.dependency_graph.dependency_tree.get_node(child_ref.get_unique_key()) + assert node.parent.package.source_path == parent.root + assert node.package.source_path == child.root + assert not child.root.is_relative_to(consumer.root) + assert decoy.manifest_path.is_file() + assert child_ref.local_path == "../child" + assert child_ref.declaring_parent == parent.root.as_posix() + assert child_ref.anchored_local_path == child.root.as_posix() + assert child_ref.get_unique_key() in ctx.callback_downloaded + materialized = LocalDependencySource( + ctx, child_ref, child_ref.get_install_path(modules), child_ref.get_unique_key() + ).acquire() + assert materialized is not None + assert materialized.package_info.package.source_path == child.root + assert ( + child_ref.get_install_path(modules) + .joinpath(".apm", "prompts", "child.prompt.md") + .read_bytes() + == source.read_bytes() + ) + ctx.downloader.download_package.assert_not_called() + finally: + staging.rollback() + + +@pytest.mark.req("req-mf-016") +@pytest.mark.parametrize( + "context", ["direct", "missing-parent", "missing-source", "relative-source", "remote"] +) +def test_user_relative_admission_requires_proven_local_parent(tmp_path: Path, context: str) -> None: + """Existing files and a claimed anchor alone cannot authorize a global read.""" + child = LocalPackageFactory(tmp_path).create("child") + dep = DependencyReference.parse("./child") + if context != "direct": + dep.declaring_parent = tmp_path.as_posix() + parent = APMPackage( + name="parent", + version="1.0.0", + source="org/remote" if context == "remote" else "_local/parent", + source_path=( + None + if context == "missing-source" + else Path("relative") + if context == "relative-source" + else tmp_path + ), + ) + reason = user_scope_rejection_reason( + dep, InstallScope.USER, parent_pkg=None if context == "missing-parent" else parent + ) + assert child.manifest_path.is_file() + assert reason is not None + assert "relative local paths" in reason + assert "absolute path" in reason + assert ( + user_scope_rejection_reason( + DependencyReference.parse(child.root.as_posix()), InstallScope.USER + ) + is None + ) + + +@pytest.mark.req("req-mf-016") +@pytest.mark.parametrize("case", ["project-relative", "user-absolute", "user-home"]) +def test_direct_local_source_is_anchored_before_copy( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, case: str +) -> None: + """Absolute/home sources work globally; project-relative sources use the project.""" + package = LocalPackageFactory(tmp_path / "sources").create("package") + consumer = tmp_path / "consumer" + consumer.mkdir() + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + reference = { + "project-relative": "../sources/package", + "user-absolute": package.root.as_posix(), + "user-home": "~/sources/package", + }[case] + scope = InstallScope.PROJECT if case == "project-relative" else InstallScope.USER + dep = DependencyReference.parse(reference) + assert user_scope_rejection_reason(dep, scope) is None + destination = consumer / "apm_modules" / "_local" / "package" + assert ( + _copy_local_package(dep, destination, consumer, project_root=consumer, logger=None) + == destination + ) + assert (destination / "apm.yml").read_bytes() == package.manifest_path.read_bytes() + + +@pytest.mark.req("req-mf-016") +@pytest.mark.parametrize("reference", ["../child", "..\\child"]) +def test_remote_relative_child_retains_repository_and_ref(tmp_path: Path, reference: str) -> None: + """A sibling inside the remote repository remains remote, not a host-file read.""" + modules = tmp_path / "apm_modules" + factory = LocalPackageFactory(modules / "repo" / "packages") + parent = factory.create("parent") + child = factory.create("child") + parent_dep = DependencyReference.parse_from_dict( + { + "git": "https://gitlab.example.invalid:8443/org/repo", + "path": "packages/parent", + "ref": "a" * 40, + } + ) + parent_pkg = APMPackage.from_apm_yml(parent.manifest_path, source_path=parent.root) + parent_pkg.source = parent_dep.repo_url + resolver = APMDependencyResolver(apm_modules_dir=modules) + expanded = resolver._expand_or_reject_remote_parent_local_path( + parent_dep, parent_pkg, DependencyReference.parse(reference) + ) + assert child.manifest_path.is_file() + assert expanded is not None + assert not expanded.is_local + assert expanded.local_path is None + assert expanded.virtual_path == "packages/child" + assert ( + expanded.host, + expanded.port, + expanded.repo_url, + expanded.reference, + expanded.explicit_scheme, + ) == ( + parent_dep.host, + parent_dep.port, + parent_dep.repo_url, + parent_dep.reference, + parent_dep.explicit_scheme, + ) + assert not resolver._rejected_remote_local_keys + + +@pytest.mark.req("req-mf-016") +@pytest.mark.parametrize( + "kind", ["escape", "absolute", "absolute-inside", "home", "windows-absolute", "symlink"] +) +def test_remote_parent_cannot_expand_into_host_files( + tmp_path: Path, capsys: pytest.CaptureFixture[str], kind: str +) -> None: + """The real expansion and loader gates refuse remote-to-host transitions.""" + modules = tmp_path / "apm_modules" + parent = LocalPackageFactory(modules / "repo" / "packages").create("parent") + outside = LocalPackageFactory(tmp_path).create("outside") + parent_dep = DependencyReference.parse_from_dict( + {"git": "https://gitlab.example.invalid/org/repo", "path": "packages/parent", "ref": "v1"} + ) + parent_pkg = APMPackage.from_apm_yml(parent.manifest_path, source_path=parent.root) + parent_pkg.source = parent_dep.repo_url + paths = { + "escape": "../../../../outside", + "absolute": outside.root.as_posix(), + "absolute-inside": parent.root.as_posix(), + "home": "~/outside", + "windows-absolute": "C:/outside", + "symlink": "../linked", + } + if kind == "symlink": + (parent.root.parent / "linked").symlink_to(outside.root, target_is_directory=True) + dep = DependencyReference.parse_from_dict({"path": paths[kind]}) + callback = MagicMock(side_effect=AssertionError("Rejected remote path reached acquisition")) + resolver = APMDependencyResolver(apm_modules_dir=modules, download_callback=callback) + assert resolver._expand_or_reject_remote_parent_local_path(parent_dep, parent_pkg, dep) is None + assert dep.get_unique_key() in resolver._rejected_remote_local_keys + assert resolver._try_load_dependency_package(dep, parent_pkg=parent_pkg) is None + callback.assert_not_called() + assert outside.manifest_path.is_file() + assert not (modules / "_local").exists() + assert paths[kind] in "".join(capsys.readouterr().out.split()) + + +@pytest.mark.req("req-mf-016") +@pytest.mark.parametrize( + "selected_alias", [False, True], ids=["source-directory", "resolved-source-alias"] +) +def test_selected_local_root_allows_only_internal_symlink_content( + tmp_path: Path, selected_alias: bool +) -> None: + """Selecting a source-directory alias is distinct from following its contents.""" + package = LocalPackageFactory(tmp_path / "sources").create("package") + target = package.root / "content.txt" + target.write_text("Internal content\n", encoding="ascii") + (package.root / "link.txt").symlink_to("content.txt") + selected = package.root + if selected_alias: + selected = tmp_path / "chosen-package" + selected.symlink_to(package.root, target_is_directory=True) + consumer = tmp_path / "consumer" + destination = consumer / "apm_modules" / "_local" / "package" + result = _copy_local_package( + DependencyReference.parse(selected.as_posix()), + destination, + consumer, + project_root=consumer, + logger=None, + ) + assert result == destination + assert (destination / "link.txt").read_bytes() == target.read_bytes() + assert not (destination / "link.txt").is_symlink() + assert (destination / "content.txt").read_bytes() == target.read_bytes() + + +@pytest.mark.req("req-mf-016") +@pytest.mark.parametrize("kind", ["outside-file", "outside-directory", "broken", "directory-cycle"]) +def test_local_package_rejects_uncontained_or_unresolvable_symlink( + tmp_path: Path, kind: str +) -> None: + """A selected local source cannot import external or invalid link content.""" + package = LocalPackageFactory(tmp_path / "sources").create("package") + outside = tmp_path / "outside" + outside.mkdir() + secret = outside / "not-package-content.txt" + secret.write_text("Outside content\n", encoding="ascii") + targets = { + "outside-file": secret, + "outside-directory": outside, + "broken": package.root / "missing", + "directory-cycle": package.root, + } + link = package.root / "linked" + link.symlink_to( + targets[kind], target_is_directory=kind in {"outside-directory", "directory-cycle"} + ) + destination = tmp_path / "consumer" / "apm_modules" / "_local" / "package" + with pytest.raises(PathTraversalError) as error: + _copy_local_package( + DependencyReference.parse(package.root.as_posix()), + destination, + tmp_path / "consumer", + project_root=tmp_path / "consumer", + logger=None, + ) + assert "linked" in str(error.value) + assert not destination.exists() + assert secret.read_text(encoding="ascii") == "Outside content\n" + + +@pytest.mark.req("req-mf-016") +def test_local_package_rejects_file_symlink_cycle(tmp_path: Path) -> None: + """An OS-detected file cycle also fails; no whole-install rollback is promised.""" + package = LocalPackageFactory(tmp_path / "sources").create("package") + (package.root / "linked").symlink_to("linked") + destination = tmp_path / "consumer" / "apm_modules" / "_local" / "package" + # pathlib reports a cycle as RuntimeError on Python 3.12; newer versions + # use OSError, which the copier translates to PathTraversalError. + with pytest.raises((PathTraversalError, RuntimeError), match="linked"): + _copy_local_package( + DependencyReference.parse(package.root.as_posix()), + destination, + tmp_path / "consumer", + project_root=tmp_path / "consumer", + logger=None, + ) + assert not (destination / "linked").exists() diff --git a/tests/spec_conformance/test_manifest_reqs.py b/tests/spec_conformance/test_manifest_reqs.py index 47c4f70c40..692f9321e7 100644 --- a/tests/spec_conformance/test_manifest_reqs.py +++ b/tests/spec_conformance/test_manifest_reqs.py @@ -179,19 +179,6 @@ def test_producer_rejects_unknown_registries_keys(): validate_against("manifest-v0.1.schema.json", doc) -@pytest.mark.req("req-mf-016") -def test_consumer_rejects_absolute_paths_in_apm_source(): - """Spec restricts apm-source `path:` to relative form.""" - assert_spec_contains("path") - waive( - "Path-shape negative test requires apm_cli's path-policy loader " - "to be invokable from the test harness; the JSON Schema currently " - "models `path` as a free-form string. Tracked as a follow-up: " - "tighten the schema to forbid leading `/` and document the " - "absolute-path rejection in the schema additionalProperties." - ) - - @pytest.mark.req("req-mf-017") def test_producer_publishes_apm_yml_at_repo_root(): assert_spec_contains("apm.yml") From 768a4e2088e626640f56d5aa889b6c71b8992c44 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sun, 6 Sep 2026 13:03:19 +0200 Subject: [PATCH 04/34] spec: clarify source routing and local replay limits Fold guardian technical feedback without activating a new specification version: distinguish syntactic local paths from remote-derived Git references; cover USER remote sibling/escape routing, multihop local anchors, and missing-anchor other-scope decoys. Preserve pending compatibility adjudication. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CONFORMANCE.json | 8 +- CONFORMANCE.md | 2 +- docs/src/content/docs/specs/openapm-v0.1.md | 24 ++- .../.apm/skills/apm-usage/dependencies.md | 8 + .../spec_conformance/test_local_path_reqs.py | 198 ++++++++++++++++-- 5 files changed, 215 insertions(+), 25 deletions(-) diff --git a/CONFORMANCE.json b/CONFORMANCE.json index f0ac331f0b..a0e6f646b7 100644 --- a/CONFORMANCE.json +++ b/CONFORMANCE.json @@ -484,7 +484,7 @@ "keyword": "MUST", "section": "4.3.5", "status": "active", - "test_count": 32, + "test_count": 36, "tests": [ "tests/spec_conformance/test_local_path_reqs.py::test_direct_local_source_is_anchored_before_copy[project-relative]", "tests/spec_conformance/test_local_path_reqs.py::test_direct_local_source_is_anchored_before_copy[user-absolute]", @@ -503,6 +503,7 @@ "tests/spec_conformance/test_local_path_reqs.py::test_local_path_prefixes_are_recognized[~\\\\child]", "tests/spec_conformance/test_local_path_reqs.py::test_local_sibling_uses_original_source_in_each_scope[InstallScope.PROJECT]", "tests/spec_conformance/test_local_path_reqs.py::test_local_sibling_uses_original_source_in_each_scope[InstallScope.USER]", + "tests/spec_conformance/test_local_path_reqs.py::test_missing_user_anchor_does_not_use_other_scope_install", "tests/spec_conformance/test_local_path_reqs.py::test_remote_parent_cannot_expand_into_host_files[absolute-inside]", "tests/spec_conformance/test_local_path_reqs.py::test_remote_parent_cannot_expand_into_host_files[absolute]", "tests/spec_conformance/test_local_path_reqs.py::test_remote_parent_cannot_expand_into_host_files[escape]", @@ -513,11 +514,14 @@ "tests/spec_conformance/test_local_path_reqs.py::test_remote_relative_child_retains_repository_and_ref[..\\\\child]", "tests/spec_conformance/test_local_path_reqs.py::test_selected_local_root_allows_only_internal_symlink_content[resolved-source-alias]", "tests/spec_conformance/test_local_path_reqs.py::test_selected_local_root_allows_only_internal_symlink_content[source-directory]", + "tests/spec_conformance/test_local_path_reqs.py::test_user_multihop_chain_keeps_each_original_anchor", "tests/spec_conformance/test_local_path_reqs.py::test_user_relative_admission_requires_proven_local_parent[direct]", "tests/spec_conformance/test_local_path_reqs.py::test_user_relative_admission_requires_proven_local_parent[missing-parent]", "tests/spec_conformance/test_local_path_reqs.py::test_user_relative_admission_requires_proven_local_parent[missing-source]", "tests/spec_conformance/test_local_path_reqs.py::test_user_relative_admission_requires_proven_local_parent[relative-source]", - "tests/spec_conformance/test_local_path_reqs.py::test_user_relative_admission_requires_proven_local_parent[remote]" + "tests/spec_conformance/test_local_path_reqs.py::test_user_relative_admission_requires_proven_local_parent[remote]", + "tests/spec_conformance/test_local_path_reqs.py::test_user_remote_paths_are_routed_before_local_admission[escape]", + "tests/spec_conformance/test_local_path_reqs.py::test_user_remote_paths_are_routed_before_local_admission[sibling]" ] }, { diff --git a/CONFORMANCE.md b/CONFORMANCE.md index 840756bde9..289ac2bfb0 100644 --- a/CONFORMANCE.md +++ b/CONFORMANCE.md @@ -74,7 +74,7 @@ All four conformance classes (Producer, Consumer, Registry, Governance) carry ac | [req-mf-013](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-013) | MUST | 4.5 | consumer | active | 1 | - | | [req-mf-014](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-014) | MUST | 4.2.3 | producer | active | 1 | - | | [req-mf-015](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-015) | MUST | 4.2.3 | producer | active | 1 | - | -| [req-mf-016](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-016) | MUST | 4.3.5 | consumer | active | 32 | - | +| [req-mf-016](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-016) | MUST | 4.3.5 | consumer | active | 36 | - | | [req-mf-017](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-017) | MUST | 4.7 | producer | active | 1 | - | | [req-mf-018](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-018) | MUST | 4.6.1 | consumer | active | 1 | - | | [req-mf-019](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-019) | MUST | 4.2.4 | consumer | active | 1 | - | diff --git a/docs/src/content/docs/specs/openapm-v0.1.md b/docs/src/content/docs/specs/openapm-v0.1.md index 392cc05fb0..494909b8fd 100644 --- a/docs/src/content/docs/specs/openapm-v0.1.md +++ b/docs/src/content/docs/specs/openapm-v0.1.md @@ -624,7 +624,11 @@ selection is configured per project. **[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 `..`: +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, @@ -645,8 +649,9 @@ 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) **User-scope admission.** A direct relative local dependency at -user scope MUST be rejected. A relative transitive local dependency +(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. A local-looking path or a recorded path string @@ -687,6 +692,16 @@ 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. +**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 @@ -3923,6 +3938,9 @@ rejection with declaring-source anchoring, trusted local sibling admission, remote-repository containment, and internal local-symlink containment. It adds executable positive and negative conformance cases in place of the skipped absolute-path-rejection claim. +The technical review fold clarifies remote-before-local admission, +adds user-scope remote, multihop, and missing-anchor controls, and +records the limits of local replay interoperability. Statement count remains **121** (116 MUST, 5 SHOULD); no requirement identifier is added, removed, or renumbered, and no file schema changes. diff --git a/packages/apm-guide/.apm/skills/apm-usage/dependencies.md b/packages/apm-guide/.apm/skills/apm-usage/dependencies.md index 9bfa17fee2..fb585de20f 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/dependencies.md +++ b/packages/apm-guide/.apm/skills/apm-usage/dependencies.md @@ -68,6 +68,14 @@ inside the same authenticated remote repo root. APM expands that path to the parent's remote host/repo/ref and fetches the sibling from the same origin. Absolute paths, paths that escape the repo root, and cross-repo local paths are rejected. +This remote expansion runs before operator-local user-scope admission: an +accepted sibling remains a Git dependency, including during a global install. + +Local lock replay still needs the original declaring-source context. Relative +spelling alone is not source identity or read authorization, and an absolute +local path is not automatically portable to another machine. The +`declaring_parent` and `anchored_local_path` fields are APM-specific metadata, +not standardized portable local-source coordinates. After a local source directory is selected and resolved, symlinks inside that package must resolve within that same directory. Internal links are copied as diff --git a/tests/spec_conformance/test_local_path_reqs.py b/tests/spec_conformance/test_local_path_reqs.py index 99a70afa99..f0f7436191 100644 --- a/tests/spec_conformance/test_local_path_reqs.py +++ b/tests/spec_conformance/test_local_path_reqs.py @@ -2,6 +2,9 @@ from __future__ import annotations +import shutil +from collections.abc import Iterator +from contextlib import contextmanager from pathlib import Path from unittest.mock import MagicMock @@ -27,6 +30,34 @@ pytestmark = pytest.mark.component +@contextmanager +def _resolved_scope( + manifest: Path, scope: InstallScope, downloader: MagicMock | None = None +) -> Iterator[InstallContext]: + """Run the real resolution phase with fixture transport and scoped storage.""" + root = manifest.parent + package = APMPackage.from_apm_yml(manifest, source_path=root) + modules = root / "apm_modules" + modules.mkdir() + ctx = InstallContext( + project_root=root, + apm_dir=root, + apm_package=package, + scope=scope, + all_apm_deps=package.get_apm_dependencies(), + apm_modules_dir=modules, + ref_freshness_policy=RefFreshnessPolicy.REPRODUCIBLE, + downloader=downloader if downloader is not None else MagicMock(shared_clone_cache=None), + diagnostics=DiagnosticCollector(), + ) + staging = ResolutionStagingSession(modules) + try: + _resolve_dependencies(ctx, staging, _materialization.CachedMaterializationPathReader()) + yield ctx + finally: + staging.rollback() + + @pytest.mark.req("req-mf-016") @pytest.mark.parametrize( "reference", ["./child", "../child", "/child", "~/child", ".\\child", "..\\child", "~\\child"] @@ -56,23 +87,8 @@ def test_local_sibling_uses_original_source_in_each_scope( decoy = LocalPackageFactory(unrelated).create("child") (unrelated / "cwd").mkdir() monkeypatch.chdir(unrelated / "cwd") - package = APMPackage.from_apm_yml(consumer.manifest_path, source_path=consumer.root) - modules = consumer.root / "apm_modules" - modules.mkdir() - ctx = InstallContext( - project_root=consumer.root, - apm_dir=consumer.root, - apm_package=package, - scope=scope, - all_apm_deps=package.get_apm_dependencies(), - apm_modules_dir=modules, - ref_freshness_policy=RefFreshnessPolicy.REPRODUCIBLE, - downloader=MagicMock(shared_clone_cache=None), - diagnostics=DiagnosticCollector(), - ) - staging = ResolutionStagingSession(modules) - try: - _resolve_dependencies(ctx, staging, _materialization.CachedMaterializationPathReader()) + with _resolved_scope(consumer.manifest_path, scope) as ctx: + modules = ctx.apm_modules_dir assert not ctx.callback_failures assert {dep.repo_url for dep in ctx.deps_to_install} == {"_local/parent", "_local/child"} child_ref = next(dep for dep in ctx.deps_to_install if dep.repo_url == "_local/child") @@ -97,8 +113,152 @@ def test_local_sibling_uses_original_source_in_each_scope( == source.read_bytes() ) ctx.downloader.download_package.assert_not_called() - finally: - staging.rollback() + + +@pytest.mark.req("req-mf-016") +def test_user_multihop_chain_keeps_each_original_anchor( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Grandchildren resolve from their immediate source parent, never an ancestor.""" + first = LocalPackageFactory(tmp_path / "sources" / "first") + second = LocalPackageFactory(tmp_path / "sources" / "second") + grandchild = second.create("grandchild") + child = second.create("child", dependencies=[{"path": "../grandchild"}]) + parent = first.create("parent", dependencies=[{"path": "../../second/child"}]) + ancestor_decoy = first.create("grandchild", version="9.9.9") + unrelated = LocalPackageFactory(tmp_path / "unrelated") + cwd_decoy = unrelated.create("grandchild", version="8.8.8") + cwd = unrelated.create("cwd") + monkeypatch.chdir(cwd.root) + consumer = LocalPackageFactory(tmp_path / "user").create( + "consumer", dependencies=[{"path": parent.root.as_posix()}] + ) + with _resolved_scope(consumer.manifest_path, InstallScope.USER) as ctx: + assert not ctx.callback_failures + refs = {dep.repo_url: dep for dep in ctx.deps_to_install} + assert set(refs) == {"_local/parent", "_local/child", "_local/grandchild"} + for package, declaring, spelling in ( + (child, parent, "../../second/child"), + (grandchild, child, "../grandchild"), + ): + ref = refs[f"_local/{package.name}"] + node = ctx.dependency_graph.dependency_tree.get_node(ref.get_unique_key()) + assert node.parent.package.source_path == declaring.root + assert node.package.source_path == package.root + assert ctx.dep_base_dirs[ref.get_unique_key()] == declaring.root + assert ref.local_path == spelling + assert ref.anchored_local_path == package.root.as_posix() + materialized = LocalDependencySource( + ctx, ref, ref.get_install_path(ctx.apm_modules_dir), ref.get_unique_key() + ).acquire() + assert materialized is not None + assert materialized.package_info.package.source_path == package.root + assert (materialized.install_path / "apm.yml").read_bytes() == ( + package.manifest_path.read_bytes() + ) + assert ancestor_decoy.manifest_path.is_file() + assert cwd_decoy.manifest_path.is_file() + assert ( + refs["_local/grandchild"] + .get_install_path(ctx.apm_modules_dir) + .joinpath("apm.yml") + .read_bytes() + != ancestor_decoy.manifest_path.read_bytes() + ) + ctx.downloader.download_package.assert_not_called() + + +@pytest.mark.req("req-mf-016") +def test_missing_user_anchor_does_not_use_other_scope_install( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Recorded identity and a matching project installation do not authorize replay.""" + factory = LocalPackageFactory(tmp_path / "sources") + child = factory.create("child") + parent = factory.create("parent", dependencies=[{"path": "../child"}]) + consumer = LocalPackageFactory(tmp_path / "user").create( + "consumer", dependencies=[{"path": parent.root.as_posix()}] + ) + project = LocalPackageFactory(tmp_path / "project").create("consumer") + monkeypatch.chdir(project.root) + with _resolved_scope(consumer.manifest_path, InstallScope.USER) as ctx: + ref = next(dep for dep in ctx.deps_to_install if dep.repo_url == "_local/child") + project_copy = ref.get_install_path(project.root / "apm_modules") + shutil.copytree(child.root, project_copy) + before = (project_copy / "apm.yml").read_bytes() + destination = ref.get_install_path(ctx.apm_modules_dir) + shutil.rmtree(destination) + node = ctx.dependency_graph.dependency_tree.get_node(ref.get_unique_key()) + node.parent.package.source_path = None + assert ref.declaring_parent and ref.anchored_local_path + assert ctx.dep_base_dirs[ref.get_unique_key()] == parent.root + assert ( + user_scope_rejection_reason(ref, InstallScope.USER, parent_pkg=node.parent.package) + is not None + ) + result = LocalDependencySource(ctx, ref, destination, ref.get_unique_key()).acquire() + assert result is None + assert not destination.exists() + assert (project_copy / "apm.yml").read_bytes() == before + assert child.manifest_path.read_bytes() == before + ctx.downloader.download_package.assert_not_called() + + +@pytest.mark.req("req-mf-016") +@pytest.mark.parametrize("reference", ["../child", "../../../outside"], ids=["sibling", "escape"]) +def test_user_remote_paths_are_routed_before_local_admission( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, reference: str +) -> None: + """Real USER resolution expands authenticated Git paths before local admission.""" + factory = LocalPackageFactory(tmp_path / "remote" / "packages") + parent = factory.create("parent", dependencies=[{"path": reference}]) + child = factory.create("child") + outside = LocalPackageFactory(tmp_path).create("outside") + remote = { + "git": "https://gitlab.example.invalid:8443/org/repo", + "path": "packages/parent", + "ref": "a" * 40, + } + consumer = LocalPackageFactory(tmp_path / "user").create("consumer", dependencies=[remote]) + fixtures = {"packages/parent": parent, "packages/child": child} + + def download(dep: DependencyReference, destination: Path) -> None: + assert not dep.is_local and dep.local_path is None + shutil.copytree(fixtures[dep.virtual_path].root, destination) + + downloader = MagicMock(shared_clone_cache=None) + downloader.download_package.side_effect = download + local_copy = MagicMock(side_effect=AssertionError("Remote path reached local acquisition")) + monkeypatch.setattr("apm_cli.install.phases.local_content._copy_local_package", local_copy) + with _resolved_scope(consumer.manifest_path, InstallScope.USER, downloader) as ctx: + requested = [call.args[0] for call in downloader.download_package.call_args_list] + expected_paths = ["packages/parent"] + if reference == "../child": + expected_paths.append("packages/child") + assert not ctx.callback_failures + else: + assert ctx.callback_failures == {DependencyReference.parse(reference).get_unique_key()} + assert [dep.virtual_path for dep in requested] == expected_paths + assert {dep.virtual_path for dep in ctx.deps_to_install} == set(expected_paths) + original = DependencyReference.parse_from_dict(remote) + for dep in requested: + assert (dep.host, dep.port, dep.repo_url, dep.reference, dep.explicit_scheme) == ( + original.host, + original.port, + original.repo_url, + original.reference, + original.explicit_scheme, + ) + assert user_scope_rejection_reason(dep, InstallScope.USER) is None + assert dep.get_unique_key() in ctx.callback_downloaded + node = ctx.dependency_graph.dependency_tree.get_node(dep.get_unique_key()) + assert node.package.source_path.is_relative_to(ctx.apm_modules_dir) + assert dep.get_install_path(ctx.apm_modules_dir).joinpath("apm.yml").read_bytes() == ( + fixtures[dep.virtual_path].manifest_path.read_bytes() + ) + assert not (ctx.apm_modules_dir / "_local").exists() + assert outside.manifest_path.is_file() + local_copy.assert_not_called() @pytest.mark.req("req-mf-016") From c457d9122c7a9ced3ea03733ae761e1e7e685f7f Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sun, 6 Sep 2026 13:22:23 +0200 Subject: [PATCH 05/34] fix: require established declaring-source provenance Remote repositories named _local/parent must remain remote. Project transient source kind from the actual parsed acquisition route across every resolver load path without contaminating cached manifests. Fail closed at loader and acquisition boundaries when transitive local provenance is absent or non-local, preserving direct local selection and same-repository remote expansion. Cover PROJECT and USER relative/absolute refusal, genuine local and remote controls, source layouts, cache isolation, and architecture ownership. Keep specification version activation with the coordinator. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../owners/install-deployment.json | 9 +- CHANGELOG.md | 4 + CONFORMANCE.json | 18 +- CONFORMANCE.md | 2 +- docs/src/content/docs/specs/openapm-v0.1.md | 13 +- .../.apm/skills/apm-usage/dependencies.md | 3 + .../checks/install_request_and_source.py | 36 ++++ src/apm_cli/deps/apm_resolver.py | 64 +++---- src/apm_cli/install/package_resolution.py | 17 +- src/apm_cli/install/sources.py | 5 +- src/apm_cli/models/apm_package.py | 4 +- .../test_architecture_owner_rule_mutations.py | 8 + .../test_deps_resolver_resolution.py | 11 +- .../spec_conformance/test_local_path_reqs.py | 33 +++- tests/test_apm_resolver.py | 10 +- .../unit/deps/test_apm_resolver_edge_cases.py | 7 +- tests/unit/deps/test_apm_resolver_phase3.py | 7 +- .../deps/test_declaring_source_provenance.py | 169 ++++++++++++++++++ .../test_user_scope_rejection_reason.py | 8 +- 19 files changed, 359 insertions(+), 69 deletions(-) create mode 100644 tests/unit/deps/test_declaring_source_provenance.py diff --git a/.apm/architecture/owners/install-deployment.json b/.apm/architecture/owners/install-deployment.json index 461f1ce478..a189ce069b 100644 --- a/.apm/architecture/owners/install-deployment.json +++ b/.apm/architecture/owners/install-deployment.json @@ -97,9 +97,12 @@ }, { "id": "local-dependency-scope-admission", - "decision": "Local dependency admission at user scope", - "owner": "install/package_resolution.py (user_scope_rejection_reason)", - "selectors": ["src/apm_cli/install/package_resolution.py"], + "decision": "Declaring-source provenance and local dependency scope admission", + "owner": "deps/apm_resolver.py (_source_kind_for_dependency) projects acquisition provenance onto APMPackage; install/package_resolution.py (user_scope_rejection_reason) owns admission", + "selectors": [ + "src/apm_cli/install/package_resolution.py", + "src/apm_cli/deps/apm_resolver.py" + ], "guards": ["install-deployment-local-scope-admission"] }, { diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d2ef6eefa..f768b28fc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Security + +- Keep remote dependencies with local-looking repository names behind remote source containment; only established local acquisition context authorizes transitive local reads. (#2820) + ### Fixed - Global installs retain local relative children using their declaring source; `docs/src/content/docs/specs/openapm-v0.1.md` reconciles trusted-local anchoring with remote and internal-symlink containment, backed by executable conformance cases. (#2820) diff --git a/CONFORMANCE.json b/CONFORMANCE.json index a0e6f646b7..bc054379b9 100644 --- a/CONFORMANCE.json +++ b/CONFORMANCE.json @@ -484,7 +484,7 @@ "keyword": "MUST", "section": "4.3.5", "status": "active", - "test_count": 36, + "test_count": 46, "tests": [ "tests/spec_conformance/test_local_path_reqs.py::test_direct_local_source_is_anchored_before_copy[project-relative]", "tests/spec_conformance/test_local_path_reqs.py::test_direct_local_source_is_anchored_before_copy[user-absolute]", @@ -510,6 +510,18 @@ "tests/spec_conformance/test_local_path_reqs.py::test_remote_parent_cannot_expand_into_host_files[home]", "tests/spec_conformance/test_local_path_reqs.py::test_remote_parent_cannot_expand_into_host_files[symlink]", "tests/spec_conformance/test_local_path_reqs.py::test_remote_parent_cannot_expand_into_host_files[windows-absolute]", + "tests/spec_conformance/test_local_path_reqs.py::test_remote_paths_are_routed_before_local_admission[absolute-_local/parent-InstallScope.PROJECT]", + "tests/spec_conformance/test_local_path_reqs.py::test_remote_paths_are_routed_before_local_admission[absolute-_local/parent-InstallScope.USER]", + "tests/spec_conformance/test_local_path_reqs.py::test_remote_paths_are_routed_before_local_admission[absolute-org/repo-InstallScope.PROJECT]", + "tests/spec_conformance/test_local_path_reqs.py::test_remote_paths_are_routed_before_local_admission[absolute-org/repo-InstallScope.USER]", + "tests/spec_conformance/test_local_path_reqs.py::test_remote_paths_are_routed_before_local_admission[escape-_local/parent-InstallScope.PROJECT]", + "tests/spec_conformance/test_local_path_reqs.py::test_remote_paths_are_routed_before_local_admission[escape-_local/parent-InstallScope.USER]", + "tests/spec_conformance/test_local_path_reqs.py::test_remote_paths_are_routed_before_local_admission[escape-org/repo-InstallScope.PROJECT]", + "tests/spec_conformance/test_local_path_reqs.py::test_remote_paths_are_routed_before_local_admission[escape-org/repo-InstallScope.USER]", + "tests/spec_conformance/test_local_path_reqs.py::test_remote_paths_are_routed_before_local_admission[sibling-_local/parent-InstallScope.PROJECT]", + "tests/spec_conformance/test_local_path_reqs.py::test_remote_paths_are_routed_before_local_admission[sibling-_local/parent-InstallScope.USER]", + "tests/spec_conformance/test_local_path_reqs.py::test_remote_paths_are_routed_before_local_admission[sibling-org/repo-InstallScope.PROJECT]", + "tests/spec_conformance/test_local_path_reqs.py::test_remote_paths_are_routed_before_local_admission[sibling-org/repo-InstallScope.USER]", "tests/spec_conformance/test_local_path_reqs.py::test_remote_relative_child_retains_repository_and_ref[../child]", "tests/spec_conformance/test_local_path_reqs.py::test_remote_relative_child_retains_repository_and_ref[..\\\\child]", "tests/spec_conformance/test_local_path_reqs.py::test_selected_local_root_allows_only_internal_symlink_content[resolved-source-alias]", @@ -519,9 +531,7 @@ "tests/spec_conformance/test_local_path_reqs.py::test_user_relative_admission_requires_proven_local_parent[missing-parent]", "tests/spec_conformance/test_local_path_reqs.py::test_user_relative_admission_requires_proven_local_parent[missing-source]", "tests/spec_conformance/test_local_path_reqs.py::test_user_relative_admission_requires_proven_local_parent[relative-source]", - "tests/spec_conformance/test_local_path_reqs.py::test_user_relative_admission_requires_proven_local_parent[remote]", - "tests/spec_conformance/test_local_path_reqs.py::test_user_remote_paths_are_routed_before_local_admission[escape]", - "tests/spec_conformance/test_local_path_reqs.py::test_user_remote_paths_are_routed_before_local_admission[sibling]" + "tests/spec_conformance/test_local_path_reqs.py::test_user_relative_admission_requires_proven_local_parent[remote]" ] }, { diff --git a/CONFORMANCE.md b/CONFORMANCE.md index 289ac2bfb0..e7e4204c20 100644 --- a/CONFORMANCE.md +++ b/CONFORMANCE.md @@ -74,7 +74,7 @@ All four conformance classes (Producer, Consumer, Registry, Governance) carry ac | [req-mf-013](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-013) | MUST | 4.5 | consumer | active | 1 | - | | [req-mf-014](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-014) | MUST | 4.2.3 | producer | active | 1 | - | | [req-mf-015](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-015) | MUST | 4.2.3 | producer | active | 1 | - | -| [req-mf-016](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-016) | MUST | 4.3.5 | consumer | active | 36 | - | +| [req-mf-016](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-016) | MUST | 4.3.5 | consumer | active | 46 | - | | [req-mf-017](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-017) | MUST | 4.7 | producer | active | 1 | - | | [req-mf-018](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-018) | MUST | 4.6.1 | consumer | active | 1 | - | | [req-mf-019](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-019) | MUST | 4.2.4 | consumer | active | 1 | - | diff --git a/docs/src/content/docs/specs/openapm-v0.1.md b/docs/src/content/docs/specs/openapm-v0.1.md index 494909b8fd..d5fe2c62e0 100644 --- a/docs/src/content/docs/specs/openapm-v0.1.md +++ b/docs/src/content/docs/specs/openapm-v0.1.md @@ -654,8 +654,12 @@ 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. A local-looking path or a recorded path string -alone is not proof that its parent is local. The consumer MUST NOT +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. @@ -3198,6 +3202,11 @@ every stored hash, foreclosing algorithm-ambiguity attacks. | 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 admission via path or repository-name camouflage | [req-mf-016](#req-mf-016); established source provenance, original anchors, and source containment | 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 for v0.2) diff --git a/packages/apm-guide/.apm/skills/apm-usage/dependencies.md b/packages/apm-guide/.apm/skills/apm-usage/dependencies.md index fb585de20f..183fd8a561 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/dependencies.md +++ b/packages/apm-guide/.apm/skills/apm-usage/dependencies.md @@ -70,6 +70,9 @@ Absolute paths, paths that escape the repo root, and cross-repo local paths are rejected. This remote expansion runs before operator-local user-scope admission: an accepted sibling remains a Git dependency, including during a global install. +APM establishes that origin from acquisition context, not repository spelling; +a remote repository named `_local/parent` is still remote. Missing provenance +does not authorize a transitive local filesystem read. Local lock replay still needs the original declaring-source context. Relative spelling alone is not source identity or read authorization, and an absolute diff --git a/scripts/architecture_linter/checks/install_request_and_source.py b/scripts/architecture_linter/checks/install_request_and_source.py index 6b9d0cb84c..6b9452c6c7 100644 --- a/scripts/architecture_linter/checks/install_request_and_source.py +++ b/scripts/architecture_linter/checks/install_request_and_source.py @@ -64,6 +64,42 @@ def check_local_scope_admission(provider: FactsProvider) -> tuple[Violation, ... findings = list(failures) if not failures and not _present(owner, f"def {owner_name}("): findings.append(_summary(_GUARD_LOCAL_SCOPE, owner_path, "Missing local admission owner")) + if not failures and not _present(owner, 'parent_pkg.proven_source_kind == "local"'): + findings.append( + _summary(_GUARD_LOCAL_SCOPE, owner_path, "Local admission requires positive provenance") + ) + resolver_path = "src/apm_cli/deps/apm_resolver.py" + resolver, failures = _facts_for(provider, resolver_path, _GUARD_LOCAL_SCOPE) + findings.extend(failures) + if not failures: + index = provider.tree_index(resolver_path) + calls = ( + _attribute_calls(tuple(index.walk(index.root)), "_activate_validated_package") + if index is not None and index.root is not None + else [] + ) + if ( + len(calls) != 4 + or not all( + len(call.args) == 4 + and isinstance(call.args[3], ast.Name) + and call.args[3].id == "dep_ref" + for call in calls + ) + or not _present( + resolver, "proven_source_kind=self._source_kind_for_dependency(dep_ref)" + ) + or not _present(resolver, "kind = self._source_kind_for_dependency(parent_dep)") + or not _present(resolver, 'parent_pkg.proven_source_kind != "local"') + ): + findings.append( + _summary( + _GUARD_LOCAL_SCOPE, + resolver_path, + "All package load paths must project actual dependency origin; " + "expansion and backstop must consume established provenance", + ) + ) consumers = ( (_INSTALL_ADAPTER, "_resolve_package_references", False), ("src/apm_cli/install/phases/resolve.py", "download_callback", True), diff --git a/src/apm_cli/deps/apm_resolver.py b/src/apm_cli/deps/apm_resolver.py index e3775ec121..ff65ddbcbc 100644 --- a/src/apm_cli/deps/apm_resolver.py +++ b/src/apm_cli/deps/apm_resolver.py @@ -9,7 +9,7 @@ from concurrent.futures import ThreadPoolExecutor from dataclasses import fields, is_dataclass, replace from pathlib import Path, PureWindowsPath -from typing import TYPE_CHECKING, NoReturn, Optional, Protocol +from typing import TYPE_CHECKING, Literal, NoReturn, Optional, Protocol from ..bundle.local_bundle import route_agent_plugin_package from ..models.apm_package import APMPackage, DependencyReference @@ -500,8 +500,16 @@ def _expand_or_reject_remote_parent_local_path( child_dep: DependencyReference, ) -> DependencyReference | None: """Expand eligible remote ``path:`` deps, otherwise fail closed.""" - if not (child_dep.is_local and child_dep.local_path and self._is_remote_parent(parent_pkg)): + if not (child_dep.is_local and child_dep.local_path): return child_dep + kind = self._source_kind_for_dependency(parent_dep) + if kind == "local": + return child_dep + if kind is None: + self._reject_remote_parent_local_path( + child_dep, parent_pkg, "declaring dependency has no established source kind." + ) + return None try: return self._expand_remote_parent_local_path(parent_dep, parent_pkg, child_dep) except PathTraversalError as exc: @@ -641,6 +649,8 @@ def build_dependency_tree( tree = DependencyTree(root_package=empty_package) return tree + # Root selection is explicit local context, not manifest-provided provenance. + root_package = replace(root_package, proven_source_kind="local") # Initialize the tree tree = DependencyTree(root_package=root_package) @@ -1224,6 +1234,7 @@ def _try_load_dependency_package( validation.package, downloaded_candidate, had_existing_install, + dep_ref, ) package_type, _ = detect_package_type(install_path) @@ -1252,6 +1263,7 @@ def _try_load_dependency_package( validation.package, downloaded_candidate, had_existing_install, + dep_ref, ) # Look for apm.yml in the install path @@ -1273,6 +1285,7 @@ def _try_load_dependency_package( package, downloaded_candidate, had_existing_install, + dep_ref, ) # No manifest found self._raise_downloaded_package_error( @@ -1312,6 +1325,7 @@ def _try_load_dependency_package( package, downloaded_candidate, had_existing_install, + dep_ref, ) def _activate_validated_package( @@ -1319,8 +1333,11 @@ def _activate_validated_package( package: APMPackage, downloaded_candidate: Path | None, had_existing_install: bool, + dep_ref: DependencyReference, ) -> APMPackage: """Publish one validated candidate and remap its package paths.""" + # from_apm_yml caches package objects; provenance belongs to this acquisition. + package = replace(package, proven_source_kind=self._source_kind_for_dependency(dep_ref)) if downloaded_candidate is None or self._activation_callback is None: return package try: @@ -1403,36 +1420,23 @@ def _remap_candidate_path( return path return live_path / relative + @staticmethod + def _source_kind_for_dependency( + dep_ref: DependencyReference, + ) -> Literal["local", "git", "registry"] | None: + """Classify the actual acquisition route, never the repository spelling.""" + if dep_ref.source == "registry": + return "registry" + if dep_ref.source not in {"git", "local"}: + return None + if dep_ref.is_local: + return "local" if dep_ref.local_path else None + return "git" if dep_ref.source == "git" else None + @staticmethod def _is_remote_parent(parent_pkg: APMPackage | None) -> bool: - """Return True if *parent_pkg* is a REMOTE package (i.e. fetched via - git URL or pinned by ref/path). - - Used to gate ``local_path`` deps: only the root project and other - local packages may legitimately declare them. Remote packages - declaring a local_path is a path-confusion vector. - - SECURITY NOTE: this is a heuristic on the ``source`` field. A - sufficiently adversarial remote could spoof a local-looking source. - The downstream containment check via ``ensure_path_within`` is the - actual security boundary; this gate just produces the user-facing - error early. - """ - if parent_pkg is None or not parent_pkg.source: - return False - src = str(parent_pkg.source) - # Local deps get ``source = "_local/"`` (see DependencyReference - # construction for is_local=True). Treat that prefix as definitively - # local even though it contains a slash. - if src.startswith("_local/"): - return False - # Remote sources look like URLs or owner/repo refs. Local sources - # are filesystem paths the user typed in their apm.yml. - return ( - src.startswith(("http://", "https://", "git@", "ssh://", "git+")) - or "://" in src - or (src.count("/") >= 1 and not src.startswith((".", "/", "~"))) - ) + """Require the remote-path backstop for non-local or unknown parent origins.""" + return parent_pkg is not None and parent_pkg.proven_source_kind != "local" @staticmethod def _compute_dep_source_path( diff --git a/src/apm_cli/install/package_resolution.py b/src/apm_cli/install/package_resolution.py index 44bde1a422..043843e1ef 100644 --- a/src/apm_cli/install/package_resolution.py +++ b/src/apm_cli/install/package_resolution.py @@ -116,12 +116,13 @@ def resolve_parsed_dependency_reference( def user_scope_rejection_reason( dep_ref: Any, scope: Any, *, parent_pkg: APMPackage | None = None ) -> str | None: - """Return a validation-fail reason if *dep_ref* is invalid at user scope. + """Return a scope or declaring-source admission failure. Absolute local paths are unambiguous (#937). A relative transitive local path also has an anchor when the resolver supplies its declaring local package's original absolute source directory (#2815). Direct references and unknown, remote, or unanchored parents retain the relative-path rejection. + A transitive local read requires established local provenance at every scope. """ if scope is None: return None @@ -135,22 +136,28 @@ def user_scope_rejection_reason( # which expanduser()s local paths before consuming them: `~/pkg` is # absolute after expansion and must NOT be rejected here. if not Path(local_path).expanduser().is_absolute(): - from apm_cli.deps.apm_resolver import APMDependencyResolver - if ( local_path and dep_ref.declaring_parent and parent_pkg is not None - and parent_pkg.source + and parent_pkg.proven_source_kind == "local" and parent_pkg.source_path is not None and parent_pkg.source_path.is_absolute() - and not APMDependencyResolver._is_remote_parent(parent_pkg) ): return None return ( "relative local paths are not supported at user scope (--global). " "Use an absolute path or a remote reference (owner/repo) instead" ) + if ( + dep_ref.is_local + and (dep_ref.declaring_parent or parent_pkg is not None) + and (parent_pkg is None or parent_pkg.proven_source_kind != "local") + ): + return ( + "local dependency has no established local declaring source. " + "Use a same-repository remote reference, or explicitly select a local source" + ) if dep_ref.is_parent_repo_inheritance and scope is InstallScope.USER: return GIT_PARENT_USER_SCOPE_ERROR return None diff --git a/src/apm_cli/install/sources.py b/src/apm_cli/install/sources.py index 3ba74381fa..a99a1a1c12 100644 --- a/src/apm_cli/install/sources.py +++ b/src/apm_cli/install/sources.py @@ -152,10 +152,7 @@ def acquire(self) -> Materialization | None: package=local_path_str, ) if logger: - logger.verbose_detail( - f" Skipping {local_path_str} (relative local paths " - "are project-relative and have no root at user scope)" - ) + logger.verbose_detail(f" Skipping {local_path_str} ({scope_reject})") return None # Determine the anchor for relative ``local_path`` (#857). For diff --git a/src/apm_cli/models/apm_package.py b/src/apm_cli/models/apm_package.py index b00e786c0e..833d570960 100644 --- a/src/apm_cli/models/apm_package.py +++ b/src/apm_cli/models/apm_package.py @@ -10,7 +10,7 @@ from collections.abc import Iterator, Mapping from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal import yaml @@ -344,6 +344,8 @@ class APMPackage: # to boolean (e.g. ``{"owner/repo#v1.0": {"hooks": true}}``). allow_executables: dict[str, dict[str, bool]] | None = None agent_plugin: "AgentPlugin | None" = None + # Acquisition-only provenance; never parsed from or serialized to a manifest. + proven_source_kind: Literal["local", "git", "registry"] | None = None def __post_init__(self) -> None: """Derive the canonical target projection for compatibility callers.""" diff --git a/tests/integration/test_architecture_owner_rule_mutations.py b/tests/integration/test_architecture_owner_rule_mutations.py index 8089496ef9..af92417814 100644 --- a/tests/integration/test_architecture_owner_rule_mutations.py +++ b/tests/integration/test_architecture_owner_rule_mutations.py @@ -325,6 +325,14 @@ class MutationCase: new="user_scope_rejection_reason(dep_ref, scope, parent_pkg=None)", intent="Resolution drops the declaring local parent's source context from admission.", ), + MutationCase( + guard_id="install-deployment-local-scope-admission", + rule_id="install-deployment-local-scope-admission", + path="src/apm_cli/deps/apm_resolver.py", + old="proven_source_kind=self._source_kind_for_dependency(dep_ref)", + new='proven_source_kind="local"', + intent="Resolver assigns trusted-local provenance without acquisition evidence.", + ), MutationCase( guard_id="install-deployment-lsp-lifecycle", rule_id="install-deployment-lsp-lifecycle", diff --git a/tests/integration/test_deps_resolver_resolution.py b/tests/integration/test_deps_resolver_resolution.py index b8b158c59a..39f39751f9 100644 --- a/tests/integration/test_deps_resolver_resolution.py +++ b/tests/integration/test_deps_resolver_resolution.py @@ -549,7 +549,12 @@ def test_local_prefix_is_not_remote(self) -> None: from apm_cli.deps.apm_resolver import APMDependencyResolver from apm_cli.models.apm_package import APMPackage - pkg = APMPackage(name="local-pkg", version="1.0.0", source="_local/local-pkg") + pkg = APMPackage( + name="local-pkg", + version="1.0.0", + source="_local/local-pkg", + proven_source_kind="local", + ) assert APMDependencyResolver._is_remote_parent(pkg) is False def test_https_url_source_is_remote(self) -> None: @@ -577,7 +582,9 @@ def test_dot_slash_source_is_not_remote(self) -> None: from apm_cli.deps.apm_resolver import APMDependencyResolver from apm_cli.models.apm_package import APMPackage - pkg = APMPackage(name="local", version="1.0.0", source="./path/to/pkg") + pkg = APMPackage( + name="local", version="1.0.0", source="./path/to/pkg", proven_source_kind="local" + ) assert APMDependencyResolver._is_remote_parent(pkg) is False diff --git a/tests/spec_conformance/test_local_path_reqs.py b/tests/spec_conformance/test_local_path_reqs.py index f0f7436191..2ad599fe3f 100644 --- a/tests/spec_conformance/test_local_path_reqs.py +++ b/tests/spec_conformance/test_local_path_reqs.py @@ -205,17 +205,27 @@ def test_missing_user_anchor_does_not_use_other_scope_install( @pytest.mark.req("req-mf-016") -@pytest.mark.parametrize("reference", ["../child", "../../../outside"], ids=["sibling", "escape"]) -def test_user_remote_paths_are_routed_before_local_admission( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, reference: str +@pytest.mark.parametrize("scope", [InstallScope.PROJECT, InstallScope.USER]) +@pytest.mark.parametrize("repository", ["org/repo", "_local/parent"]) +@pytest.mark.parametrize( + "reference", ["../child", "../../../outside", "absolute"], ids=["sibling", "escape", "absolute"] +) +def test_remote_paths_are_routed_before_local_admission( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + reference: str, + repository: str, + scope: InstallScope, ) -> None: - """Real USER resolution expands authenticated Git paths before local admission.""" + """Parsed Git origin outranks scope and repository spelling before local reads.""" + outside = LocalPackageFactory(tmp_path).create("outside") + if reference == "absolute": + reference = outside.root.as_posix() factory = LocalPackageFactory(tmp_path / "remote" / "packages") parent = factory.create("parent", dependencies=[{"path": reference}]) child = factory.create("child") - outside = LocalPackageFactory(tmp_path).create("outside") remote = { - "git": "https://gitlab.example.invalid:8443/org/repo", + "git": f"https://gitlab.example.invalid:8443/{repository}", "path": "packages/parent", "ref": "a" * 40, } @@ -230,7 +240,8 @@ def download(dep: DependencyReference, destination: Path) -> None: downloader.download_package.side_effect = download local_copy = MagicMock(side_effect=AssertionError("Remote path reached local acquisition")) monkeypatch.setattr("apm_cli.install.phases.local_content._copy_local_package", local_copy) - with _resolved_scope(consumer.manifest_path, InstallScope.USER, downloader) as ctx: + with _resolved_scope(consumer.manifest_path, scope, downloader) as ctx: + local_copy.assert_not_called() requested = [call.args[0] for call in downloader.download_package.call_args_list] expected_paths = ["packages/parent"] if reference == "../child": @@ -252,11 +263,17 @@ def download(dep: DependencyReference, destination: Path) -> None: assert user_scope_rejection_reason(dep, InstallScope.USER) is None assert dep.get_unique_key() in ctx.callback_downloaded node = ctx.dependency_graph.dependency_tree.get_node(dep.get_unique_key()) + assert node.package.proven_source_kind == "git" assert node.package.source_path.is_relative_to(ctx.apm_modules_dir) assert dep.get_install_path(ctx.apm_modules_dir).joinpath("apm.yml").read_bytes() == ( fixtures[dep.virtual_path].manifest_path.read_bytes() ) - assert not (ctx.apm_modules_dir / "_local").exists() + assert all(not dep.is_local for dep in ctx.deps_to_install) + assert ( + not DependencyReference.parse(outside.root.as_posix()) + .get_install_path(ctx.apm_modules_dir) + .exists() + ) assert outside.manifest_path.is_file() local_copy.assert_not_called() diff --git a/tests/test_apm_resolver.py b/tests/test_apm_resolver.py index e13f1699e0..aa660f2295 100644 --- a/tests/test_apm_resolver.py +++ b/tests/test_apm_resolver.py @@ -480,8 +480,8 @@ def test_dependency_graph_error_handling(self): # =========================================================================== -class TestIsRemoteParentHeuristic(unittest.TestCase): - """_is_remote_parent must NOT misclassify _local/ as remote (#940).""" +class TestIsRemoteParentProvenance(unittest.TestCase): + """Explicit acquisition provenance, not location spelling, controls the backstop.""" def setUp(self): from apm_cli.deps.apm_resolver import APMDependencyResolver @@ -493,6 +493,7 @@ def test_local_underscore_prefix_is_local(self): pkg = APMPackage(name="specialized", version="1.0.0") pkg.source = "_local/specialized" + pkg.proven_source_kind = "local" self.assertFalse(self.resolver._is_remote_parent(pkg)) def test_owner_repo_slash_is_remote(self): @@ -502,11 +503,11 @@ def test_owner_repo_slash_is_remote(self): pkg.source = "microsoft/apm-sample-package" self.assertTrue(self.resolver._is_remote_parent(pkg)) - def test_no_source_is_local(self): + def test_no_provenance_requires_backstop(self): from apm_cli.models.apm_package import APMPackage pkg = APMPackage(name="root", version="1.0.0") - self.assertFalse(self.resolver._is_remote_parent(pkg)) + self.assertTrue(self.resolver._is_remote_parent(pkg)) class TestSignatureFallback(unittest.TestCase): @@ -591,6 +592,7 @@ def test_local_parent_local_path_not_rejected_or_tracked(self): resolver = APMDependencyResolver(apm_modules_dir=Path(tmpdir)) local_parent = APMPackage(name="specialized", version="1.0.0") local_parent.source = "_local/specialized" + local_parent.proven_source_kind = "local" local_dep = DependencyReference( repo_url="", diff --git a/tests/unit/deps/test_apm_resolver_edge_cases.py b/tests/unit/deps/test_apm_resolver_edge_cases.py index 4392ed4452..04fbbfae82 100644 --- a/tests/unit/deps/test_apm_resolver_edge_cases.py +++ b/tests/unit/deps/test_apm_resolver_edge_cases.py @@ -437,14 +437,15 @@ class TestIsRemoteParent: def test_none_parent_returns_false(self) -> None: assert APMDependencyResolver._is_remote_parent(None) is False - def test_parent_with_no_source_returns_false(self) -> None: + def test_parent_with_unknown_provenance_requires_backstop(self) -> None: pkg = MagicMock() pkg.source = None - assert APMDependencyResolver._is_remote_parent(pkg) is False + assert APMDependencyResolver._is_remote_parent(pkg) is True def test_local_prefix_returns_false(self) -> None: pkg = MagicMock() pkg.source = "_local/mypkg" + pkg.proven_source_kind = "local" assert APMDependencyResolver._is_remote_parent(pkg) is False def test_https_source_returns_true(self) -> None: @@ -465,11 +466,13 @@ def test_owner_repo_shorthand_returns_true(self) -> None: def test_relative_local_path_returns_false(self) -> None: pkg = MagicMock() pkg.source = "../relative/path" + pkg.proven_source_kind = "local" assert APMDependencyResolver._is_remote_parent(pkg) is False def test_absolute_local_path_returns_false(self) -> None: pkg = MagicMock() pkg.source = "/abs/local/path" + pkg.proven_source_kind = "local" assert APMDependencyResolver._is_remote_parent(pkg) is False diff --git a/tests/unit/deps/test_apm_resolver_phase3.py b/tests/unit/deps/test_apm_resolver_phase3.py index e846febd09..fa8963e482 100644 --- a/tests/unit/deps/test_apm_resolver_phase3.py +++ b/tests/unit/deps/test_apm_resolver_phase3.py @@ -436,14 +436,15 @@ class TestIsRemoteParent: def test_none_parent_returns_false(self) -> None: assert APMDependencyResolver._is_remote_parent(None) is False - def test_parent_with_no_source_returns_false(self) -> None: + def test_parent_with_unknown_provenance_requires_backstop(self) -> None: pkg = MagicMock() pkg.source = None - assert APMDependencyResolver._is_remote_parent(pkg) is False + assert APMDependencyResolver._is_remote_parent(pkg) is True def test_local_prefix_returns_false(self) -> None: pkg = MagicMock() pkg.source = "_local/mypkg" + pkg.proven_source_kind = "local" assert APMDependencyResolver._is_remote_parent(pkg) is False def test_https_source_returns_true(self) -> None: @@ -464,11 +465,13 @@ def test_owner_repo_shorthand_returns_true(self) -> None: def test_relative_local_path_returns_false(self) -> None: pkg = MagicMock() pkg.source = "../relative/path" + pkg.proven_source_kind = "local" assert APMDependencyResolver._is_remote_parent(pkg) is False def test_absolute_local_path_returns_false(self) -> None: pkg = MagicMock() pkg.source = "/abs/local/path" + pkg.proven_source_kind = "local" assert APMDependencyResolver._is_remote_parent(pkg) is False diff --git a/tests/unit/deps/test_declaring_source_provenance.py b/tests/unit/deps/test_declaring_source_provenance.py new file mode 100644 index 0000000000..514b0b6a08 --- /dev/null +++ b/tests/unit/deps/test_declaring_source_provenance.py @@ -0,0 +1,169 @@ +"""Acquisition provenance is transient context, never package-controlled spelling.""" + +from __future__ import annotations + +import shutil +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from apm_cli.core.scope import InstallScope +from apm_cli.deps.apm_resolver import APMDependencyResolver +from apm_cli.install.context import InstallContext +from apm_cli.install.resolution_staging import ResolutionStagingSession +from apm_cli.install.sources import LocalDependencySource +from apm_cli.models.apm_package import APMPackage +from apm_cli.models.dependency import DependencyReference +from apm_cli.utils.diagnostics import DiagnosticCollector +from apm_cli.utils.yaml_io import dump_yaml +from tests.utils.local_package import LocalPackageFactory + +pytestmark = pytest.mark.component + + +@pytest.mark.parametrize("kind", ["local", "git", "registry"]) +@pytest.mark.parametrize("layout", ["manifest", "skill", "native", "marketplace"]) +def test_every_loaded_layout_retains_acquisition_kind( + tmp_path: Path, kind: str, layout: str +) -> None: + """All resolver return paths carry actual origin through staging activation.""" + source = tmp_path / "source" + source.mkdir() + if layout == "manifest": + dump_yaml({"name": "package", "version": "1.0.0"}, source / "apm.yml") + elif layout == "skill": + (source / "SKILL.md").write_text( + "---\nname: package\ndescription: Fixture\n---\nContent\n", encoding="ascii" + ) + else: + fixtures = Path(__file__).resolve().parents[2] / "fixtures" + fixture = ( + fixtures / "agent_plugins" / "portable" + if layout == "native" + else fixtures / "mock-marketplace-plugin" + ) + shutil.copytree(fixture, source, dirs_exist_ok=True) + ref = ( + DependencyReference.parse(source.as_posix()) + if kind == "local" + else DependencyReference.parse_from_dict( + {"git": "https://gitlab.example.invalid/_local/parent", "ref": "v1"} + ) + ) + if kind == "registry": + ref.source = "registry" + modules = tmp_path / "modules" + modules.mkdir() + staging = ResolutionStagingSession(modules) + + def download(dep: DependencyReference, root: Path, parent_chain: str = "") -> Path: + destination = staging.prepare_replacement(dep.get_install_path(root)) + shutil.copytree(source, destination) + return destination + + resolver = APMDependencyResolver( + apm_modules_dir=modules, + download_callback=download, + activation_callback=staging.publish_replacement, + ) + try: + loaded = resolver._try_load_dependency_package(ref) + assert loaded is not None + assert loaded.proven_source_kind == kind + assert APMDependencyResolver._is_remote_parent(loaded) is (kind != "local") + assert loaded.package_path == ref.get_install_path(modules) + assert loaded.source_path == (source if kind == "local" else ref.get_install_path(modules)) + finally: + staging.rollback() + + +def test_provenance_projection_does_not_poison_manifest_cache(tmp_path: Path) -> None: + """The same cached manifest cannot gain authority from a previous acquisition.""" + package = LocalPackageFactory(tmp_path).create("parent") + dump_yaml( + { + "name": "parent", + "version": "1.0.0", + "source": "_local/forged", + "proven_source_kind": "local", + }, + package.manifest_path, + ) + cached = APMPackage.from_apm_yml(package.manifest_path, source_path=package.root) + local_ref = DependencyReference.parse(package.root.as_posix()) + remote_ref = DependencyReference.parse_from_dict( + {"git": "https://gitlab.example.invalid/_local/parent"} + ) + resolver = APMDependencyResolver() + local = resolver._activate_validated_package(cached, None, True, local_ref) + remote = resolver._activate_validated_package(cached, None, True, remote_ref) + assert local.proven_source_kind == "local" + assert remote.proven_source_kind == "git" + assert cached.proven_source_kind is None + tree = resolver.build_dependency_tree(package.root, root_package=cached) + assert tree.root_package.proven_source_kind == "local" + assert ( + APMPackage.from_apm_yml(package.manifest_path, source_path=package.root).proven_source_kind + is None + ) + assert local is not remote and local is not cached and remote is not cached + + +@pytest.mark.parametrize("scope", [InstallScope.PROJECT, InstallScope.USER]) +@pytest.mark.parametrize("kind", ["git", "registry", "unknown"]) +@pytest.mark.parametrize("absolute", [False, True], ids=["relative", "absolute"]) +@pytest.mark.parametrize("boundary", ["loader", "acquire"]) +def test_boundaries_refuse_nonlocal_declaring_context( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + scope: InstallScope, + kind: str, + absolute: bool, + boundary: str, +) -> None: + """Both backstops refuse remote/unknown parents before local acquisition.""" + factory = LocalPackageFactory(tmp_path / "packages") + child = factory.create("child") + parent = factory.create("parent") + parent_ref = DependencyReference.parse_from_dict( + {"git": "https://gitlab.example.invalid/_local/parent", "ref": "v1"} + ) + if kind == "registry": + parent_ref.source = "registry" + parent_pkg = APMPackage.from_apm_yml(parent.manifest_path, source_path=parent.root) + if kind != "unknown": + parent_pkg = APMDependencyResolver()._activate_validated_package( + parent_pkg, None, True, parent_ref + ) + child_ref = DependencyReference.parse(child.root.as_posix() if absolute else "../child") + child_ref.declaring_parent = parent_ref.get_unique_key() + child_ref.anchored_local_path = child.root.as_posix() + graph = MagicMock() + graph.dependency_tree.get_node.return_value.parent.package = parent_pkg + ctx = InstallContext( + project_root=tmp_path, + apm_dir=tmp_path, + scope=scope, + dependency_graph=graph, + diagnostics=DiagnosticCollector(), + ) + ctx.dep_base_dirs = {child_ref.get_unique_key(): parent.root} + copy = MagicMock(side_effect=AssertionError("Untrusted origin reached filesystem copy")) + monkeypatch.setattr("apm_cli.install.phases.local_content._copy_local_package", copy) + destination = child_ref.get_install_path(tmp_path / "modules") + callback = MagicMock(side_effect=AssertionError("Untrusted origin reached callback")) + if boundary == "loader": + resolver = APMDependencyResolver( + apm_modules_dir=tmp_path / "modules", download_callback=callback + ) + assert resolver._try_load_dependency_package(child_ref, parent_pkg=parent_pkg) is None + else: + materialized = LocalDependencySource( + ctx, child_ref, destination, child_ref.get_unique_key() + ) + assert materialized.acquire() is None + callback.assert_not_called() + copy.assert_not_called() + assert child.manifest_path.is_file() + assert not destination.exists() diff --git a/tests/unit/install/test_user_scope_rejection_reason.py b/tests/unit/install/test_user_scope_rejection_reason.py index c12f61f850..b5920753c2 100644 --- a/tests/unit/install/test_user_scope_rejection_reason.py +++ b/tests/unit/install/test_user_scope_rejection_reason.py @@ -155,7 +155,11 @@ def test_user_scope_handles_empty_local_path_defensively(): def test_user_scope_accepts_proven_local_parent_anchor(tmp_path: Path, local_path: str) -> None: """A resolver-proven local child is anchored, not relative to arbitrary CWD.""" parent = APMPackage( - name="parent", version="1.0.0", source="_local/parent", source_path=tmp_path + name="parent", + version="1.0.0", + source="_local/parent", + source_path=tmp_path, + proven_source_kind="local", ) child = _local_ref(local_path) child.declaring_parent = tmp_path.as_posix() @@ -173,6 +177,7 @@ def test_user_scope_accepts_proven_local_parent_anchor(tmp_path: Path, local_pat ("org/remote", "absolute", True, "../child"), ("https://example.invalid/remote", "absolute", True, "../child"), ("_local/parent", "absolute", True, ""), + ("_local/parent", "absolute", True, "../child"), ], ids=[ "direct-ref", @@ -182,6 +187,7 @@ def test_user_scope_accepts_proven_local_parent_anchor(tmp_path: Path, local_pat "remote-shorthand", "remote-url", "empty-path", + "local-looking-unknown-origin", ], ) def test_user_scope_does_not_infer_local_parent_trust( From 0f0d4b0b6ec6ae96ec2acdc47a3a86eae66a211a Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sun, 6 Sep 2026 13:26:51 +0200 Subject: [PATCH 06/34] docs(spec): prepare corrective minor and exact assessment harness Local Wave 2 working snapshot, not a publishable candidate. Preserve the previous minor and unchanged schemas; select the distinct corrective draft, keep reserved features inactive, and bind fresh collection to exact revision metadata. Audit-owner wording and immutable production integration remain pending. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../owners/contracts-tooling.json | 7 + .github/workflows/spec-conformance.yml | 5 +- .gitignore | 1 + CONFORMANCE.json | 13 +- CONFORMANCE.md | 261 +- docs/astro.config.mjs | 5 + .../manifests/openapm-v0.1.requirements.yml | 2 - .../manifests/openapm-v0.2.requirements.yml | 534 +++ docs/src/content/docs/specs/conformance.md | 16 +- docs/src/content/docs/specs/openapm-v0.1.md | 154 +- docs/src/content/docs/specs/openapm-v0.2.md | 4085 +++++++++++++++++ .../checks/contracts_test_taxonomy.py | 47 + .../test_architecture_owner_rule_mutations.py | 8 + tests/spec_conformance/__init__.py | 2 +- tests/spec_conformance/_helpers.py | 6 +- tests/spec_conformance/_manifest.py | 149 +- tests/spec_conformance/conftest.py | 14 +- tests/spec_conformance/gen_statement.py | 110 +- tests/spec_conformance/mode_b_detector.sh | 7 +- tests/spec_conformance/orphan_check.py | 52 +- tests/spec_conformance/test_gen_statement.py | 5 +- tests/spec_conformance/test_lockfile_reqs.py | 4 +- tests/spec_conformance/test_manifest_reqs.py | 10 +- .../spec_conformance/test_mode_b_detector.py | 52 +- tests/spec_conformance/test_registry_reqs.py | 4 +- .../spec_conformance/test_resolution_reqs.py | 8 +- .../test_spec_version_contract.py | 240 + .../unit/scripts/test_architecture_runner.py | 1 + 28 files changed, 5386 insertions(+), 416 deletions(-) create mode 100644 docs/public/specs/manifests/openapm-v0.2.requirements.yml create mode 100644 docs/src/content/docs/specs/openapm-v0.2.md create mode 100644 tests/spec_conformance/test_spec_version_contract.py diff --git a/.apm/architecture/owners/contracts-tooling.json b/.apm/architecture/owners/contracts-tooling.json index 58b1cff55c..be8aedb4f4 100644 --- a/.apm/architecture/owners/contracts-tooling.json +++ b/.apm/architecture/owners/contracts-tooling.json @@ -1,6 +1,13 @@ { "version": 1, "owners": [ + { + "id": "spec-assessment-selection", + "decision": "One active specification artifact, informative manifest, exact assessment identity, and fresh static binding inventory", + "owner": "tests/spec_conformance/_manifest.py", + "selectors": ["tests/spec_conformance/_manifest.py"], + "guards": ["contracts-tooling-spec-assessment"] + }, { "id": "dependency-identity-materialization", "decision": "Dependency comparison identity vs display-cased materialization path; embedded git URL subpath validation", diff --git a/.github/workflows/spec-conformance.yml b/.github/workflows/spec-conformance.yml index 0daa414319..622fecd067 100644 --- a/.github/workflows/spec-conformance.yml +++ b/.github/workflows/spec-conformance.yml @@ -1,6 +1,6 @@ name: Spec conformance -# Enforces the OpenAPM v0.1 spec-vs-implementation 4-way bind: +# Enforces the selected OpenAPM revision's spec-vs-implementation 4-way bind: # spec body anchors == requirements manifest == Appendix C rows == pytest req markers # Then runs the conformance suite, regenerates CONFORMANCE.{json,md}, # and gates on a clean git diff so contributors must commit any @@ -11,6 +11,7 @@ on: branches: [ main ] paths: - 'docs/src/content/docs/specs/**' + - 'docs/public/specs/**' - 'tests/fixtures/spec-conformance/**' - 'tests/spec_conformance/**' - 'src/apm_cli/**' @@ -46,7 +47,7 @@ jobs: env: BASE_REF: origin/${{ github.event.pull_request.base.ref || 'main' }} GH_PR_BODY: ${{ github.event.pull_request.body }} - run: bash tests/spec_conformance/mode_b_detector.sh + run: uv run --frozen --extra dev bash tests/spec_conformance/mode_b_detector.sh - name: Conformance test suite run: | diff --git a/.gitignore b/.gitignore index 09218eb718..05749cd214 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ __pycache__/ build/build/ build/apm/ build/conformance-coverage.json +build/conformance-coverage-*.json *.bak develop-eggs/ dist/ diff --git a/CONFORMANCE.json b/CONFORMANCE.json index a0e6f646b7..6b0f87ed97 100644 --- a/CONFORMANCE.json +++ b/CONFORMANCE.json @@ -4,7 +4,9 @@ "manifest_location": "~/.apm/apm.yml", "target_capability_declaration": "MCPClientAdapter.supports_user_scope (OpenAPM Target Registry v0.1 implementation profile)" }, - "generator": "gen_statement.py v1", + "generator": "gen_statement.py v2", + "inventory_kind": "static-test-bindings", + "manifest_sha256": "1b9d032b871b4b59c93b5e2548b80dc53a002cbe21c6f4dbb83dec3cdf128cfb", "requirements": [ { "conformance_class": "consumer", @@ -259,7 +261,7 @@ "tests/spec_conformance/test_lockfile_reqs.py::test_lockfile_should_record_publish_timestamp" ], "waivers": [ - "Publish-timestamp recording is a publisher-side SHOULD that requires registry interaction to exercise end-to-end. The schema affordance (generated_at) is asserted above; full publisher coverage requires the registry wire conformance module which is not in v0.1 scope." + "Publish-timestamp recording is a publisher-side SHOULD that requires registry interaction to exercise end-to-end. The schema affordance (generated_at) is asserted above; full publisher coverage requires the registry wire conformance module which remains outside this revision's scope." ] }, { @@ -576,7 +578,7 @@ "status": "active", "test_count": 1, "tests": [ - "tests/spec_conformance/test_manifest_reqs.py::test_producer_workspaces_must_not_use_in_v0_1" + "tests/spec_conformance/test_manifest_reqs.py::test_producer_workspaces_remain_reserved" ] }, { @@ -1442,7 +1444,10 @@ ] } ], - "spec_version": "v0.1.1", + "spec_citation": "https://microsoft.github.io/apm/spec/v0.2.0", + "spec_path": "docs/src/content/docs/specs/openapm-v0.2.md", + "spec_sha256": "2b673cd6404635d8ec8ee783a60abd6ad4ad94d0e257e2b59c2c3600c743d99e", + "spec_version": "v0.2.0", "summary_by_class": { "consumer": { "active": 91, diff --git a/CONFORMANCE.md b/CONFORMANCE.md index 289ac2bfb0..a3750afd64 100644 --- a/CONFORMANCE.md +++ b/CONFORMANCE.md @@ -1,18 +1,21 @@ -# OpenAPM Conformance Statement -- v0.1.1 +# OpenAPM Conformance Binding Inventory -- v0.2.0 -Generator: gen_statement.py v1. -Spec: [docs/src/content/docs/specs/openapm-v0.1.md](docs/src/content/docs/specs/openapm-v0.1.md) +Generator: gen_statement.py v2. +Spec: [docs/src/content/docs/specs/openapm-v0.2.md](docs/src/content/docs/specs/openapm-v0.2.md) +Exact revision citation: https://microsoft.github.io/apm/spec/v0.2.0 This file is generated. Do NOT edit by hand. Run `uv run python -m tests.spec_conformance.gen_statement` to regenerate. ## Honesty contract -There is NO automated CI detector for spec-vs-behaviour drift beyond the four sets enforced by `orphan_check.py`: spec anchors, manifest entries, Appendix C rows, and `@pytest.mark.req` markers. A requirement marked `status=active` is exercised by at least one assertion. A requirement marked `status=skipped` carries a written waiver below; this is debt, not coverage. A requirement with `status=xfail` is asserted-but-known-broken. +There is NO automated CI detector for spec-vs-behaviour drift beyond the four sets enforced by `orphan_check.py`: spec anchors, manifest entries, Appendix C rows, and `@pytest.mark.req` markers. Statuses are a static binding inventory from fresh full-suite collection, not executed test results or a runtime pass certificate. `status=active` means a collected binding is not statically marked skipped or xfail; it does not prove that an assertion ran or passed. `status=skipped` and `status=xfail` describe static markers or waiver calls, not measured execution outcomes. Waivers are listed below as debt. Separate test execution and implementation evidence remain necessary. + +This inventory assesses only the selected corrective revision. It does not establish historical CLI conformance to the previous minor's req-mf-016 blanket project-root refusal. A prepared specification and collected bindings do not establish publication or ratification. ## Conformance classes -All four conformance classes (Producer, Consumer, Registry, Governance) carry active coverage in this statement. The Registry class is exercised via the trust-anchor invariant test in `tests/spec_conformance/test_registry_reqs.py`, which hashes the committed Registry-archive fixture and asserts equality with the digest the paired lockfile advertises (sec.11.3.3, req-rg-001). +The four conformance classes (Producer, Consumer, Registry, Governance) are inventoried below, not certified by this report. The Registry binding includes the trust-anchor invariant test in `tests/spec_conformance/test_registry_reqs.py`, which hashes the committed Registry-archive fixture and asserts equality with the digest the paired lockfile advertises (sec.11.3.3, req-rg-001). ## Consumer user-scope disclosure @@ -20,7 +23,7 @@ All four conformance classes (Producer, Consumer, Registry, Governance) carry ac - Lockfile: `~/.apm/apm.lock.yaml` - Target capability declaration: `MCPClientAdapter.supports_user_scope (OpenAPM Target Registry v0.1 implementation profile)` -## Coverage summary +## Binding summary | Class | Active | Skipped | Xfail | Unbound | |-------|-------:|--------:|------:|--------:| @@ -29,131 +32,131 @@ All four conformance classes (Producer, Consumer, Registry, Governance) carry ac | Registry | 1 | 0 | 0 | 0 | | Governance | 17 | 0 | 0 | 0 | -## Per-requirement coverage +## Per-requirement bindings | Req ID | Keyword | Sec | Class | Status | Tests | Oracle | |--------|---------|----:|-------|--------|------:|--------| -| [req-cf-001](docs/src/content/docs/specs/openapm-v0.1.md#req-cf-001) | MUST | 12.5 | consumer | active | 6 | - | -| [req-cf-002](docs/src/content/docs/specs/openapm-v0.1.md#req-cf-002) | MUST | 12.3 | consumer | active | 1 | - | -| [req-ext-001](docs/src/content/docs/specs/openapm-v0.1.md#req-ext-001) | MUST | 4.1 | consumer | active | 1 | - | -| [req-ext-002](docs/src/content/docs/specs/openapm-v0.1.md#req-ext-002) | MUST | 4.1 | producer | active | 1 | - | -| [req-lk-001](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-001) | MUST | 5.1 | consumer | active | 1 | - | -| [req-lk-002](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-002) | MUST | 5.4 | consumer | active | 1 | - | -| [req-lk-003](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-003) | MUST | 5.2 | consumer | active | 2 | - | -| [req-lk-004](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-004) | MUST | 5.4 | consumer | active | 1 | - | -| [req-lk-005](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-005) | MUST | 5.5 | consumer | active | 2 | - | -| [req-lk-006](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-006) | MUST | 5.5 | consumer | active | 1 | - | -| [req-lk-007](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-007) | SHOULD | 5.5 | consumer | active | 1 | - | -| [req-lk-008](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-008) | MUST | 5.6 | consumer | active | 1 | - | -| [req-lk-009](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-009) | MUST | 5.6 | consumer | active | 1 | - | -| [req-lk-010](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-010) | MUST | 5.6 | consumer | active | 1 | - | -| [req-lk-011](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-011) | MUST | 5.2 | consumer | active | 1 | - | -| [req-lk-012](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-012) | MUST | 5.2 | consumer | active | 1 | - | -| [req-lk-013](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-013) | MUST | 5.2 | consumer | active | 2 | - | -| [req-lk-014](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-014) | MUST | 5.2 | consumer | active | 1 | - | -| [req-lk-015](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-015) | MUST | 5.6.4 | consumer | active | 1 | - | -| [req-lk-016](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-016) | MUST | 5.2 | consumer | active | 1 | - | -| [req-lk-017](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-017) | MUST | 5.2 | consumer | active | 1 | - | -| [req-lk-018](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-018) | SHOULD | 5.5 | consumer | active | 1 | - | -| [req-lk-019](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-019) | MUST | 5.2 | consumer | active | 1 | - | -| [req-lk-020](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-020) | MUST | 5.2 | consumer | active | 3 | - | -| [req-lk-021](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-021) | MUST | 5.2 | consumer | active | 2 | - | -| [req-lk-022](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-022) | MUST | 5.2 | consumer | active | 4 | - | -| [req-mf-001](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-001) | MUST | 4.1 | producer | active | 1 | - | -| [req-mf-002](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-002) | MUST | 4.1 | producer | active | 1 | - | -| [req-mf-003](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-003) | MUST | 4.1 | producer | active | 1 | - | -| [req-mf-004](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-004) | SHOULD | 4.1 | producer | active | 1 | - | -| [req-mf-005](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-005) | MUST | 4.2.1 | producer | active | 1 | - | -| [req-mf-006](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-006) | MUST | 4.1 | consumer | active | 1 | - | -| [req-mf-007](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-007) | MUST | 4.3.1 | consumer | active | 1 | - | -| [req-mf-008](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-008) | MUST | 4.3.3 | consumer | active | 1 | - | -| [req-mf-009](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-009) | MUST | 4.3.4 | consumer | active | 1 | - | -| [req-mf-010](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-010) | MUST | 4.3.2 | consumer | active | 1 | - | -| [req-mf-011](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-011) | MUST | 4.3.2 | consumer | active | 1 | - | -| [req-mf-012](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-012) | MUST | 4.3.6 | consumer | active | 1 | - | -| [req-mf-013](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-013) | MUST | 4.5 | consumer | active | 1 | - | -| [req-mf-014](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-014) | MUST | 4.2.3 | producer | active | 1 | - | -| [req-mf-015](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-015) | MUST | 4.2.3 | producer | active | 1 | - | -| [req-mf-016](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-016) | MUST | 4.3.5 | consumer | active | 36 | - | -| [req-mf-017](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-017) | MUST | 4.7 | producer | active | 1 | - | -| [req-mf-018](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-018) | MUST | 4.6.1 | consumer | active | 1 | - | -| [req-mf-019](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-019) | MUST | 4.2.4 | consumer | active | 1 | - | -| [req-mf-020](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-020) | MUST | 4.1 | consumer | active | 1 | - | -| [req-mf-021](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-021) | MUST | 4.8 | producer | active | 1 | - | -| [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-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 | - | -| [req-pl-004](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-004) | MUST | 6.4 | governance | active | 1 | - | -| [req-pl-005](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-005) | MUST | 6.5 | governance | active | 1 | - | -| [req-pl-006](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-006) | MUST | 6.4 | governance | active | 1 | - | -| [req-pl-007](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-007) | MUST | 6.3.1 | governance | active | 1 | - | -| [req-pl-008](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-008) | MUST | 6.3.1 | governance | active | 1 | - | -| [req-pl-009](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-009) | MUST | 6.6 | governance | active | 1 | - | -| [req-pl-010](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-010) | MUST | 6.2 | governance | active | 1 | - | -| [req-pl-011](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-011) | MUST | 6.1.1 | governance | active | 2 | - | -| [req-pl-012](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-012) | MUST | 6.1.1 | governance | active | 1 | - | -| [req-pl-013](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-013) | MUST | 6.8 | governance | active | 1 | - | -| [req-pl-014](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-014) | MUST | 6.8 | governance | active | 1 | - | -| [req-pl-015](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-015) | MUST | 6.3.5 | governance | active | 1 | - | -| [req-pl-016](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-016) | MUST | 6.8 | governance | active | 1 | - | -| [req-pl-017](docs/src/content/docs/specs/openapm-v0.1.md#req-pl-017) | MUST | 6.8 | governance | active | 1 | - | -| [req-pr-001](docs/src/content/docs/specs/openapm-v0.1.md#req-pr-001) | MUST | 8.2 | consumer | active | 1 | - | -| [req-pr-002](docs/src/content/docs/specs/openapm-v0.1.md#req-pr-002) | MUST | 8.3 | consumer | active | 1 | - | -| [req-pr-003](docs/src/content/docs/specs/openapm-v0.1.md#req-pr-003) | MUST | 8.3 | consumer | active | 1 | - | -| [req-pr-004](docs/src/content/docs/specs/openapm-v0.1.md#req-pr-004) | MUST | 7.8 | producer | active | 10 | - | -| [req-pr-005](docs/src/content/docs/specs/openapm-v0.1.md#req-pr-005) | SHOULD | 7.8 | producer | active | 1 | - | -| [req-pr-006](docs/src/content/docs/specs/openapm-v0.1.md#req-pr-006) | MUST | 8.1 | consumer | active | 1 | - | -| [req-pr-007](docs/src/content/docs/specs/openapm-v0.1.md#req-pr-007) | MUST | 8.1 | consumer | active | 1 | - | -| [req-rg-001](docs/src/content/docs/specs/openapm-v0.1.md#req-rg-001) | MUST | 11.3.3 | registry | active | 1 | - | -| [req-rs-001](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-001) | MUST | 7.2 | consumer | active | 1 | - | -| [req-rs-002](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-002) | MUST | 7.3 | consumer | active | 1 | - | -| [req-rs-003](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-003) | MUST | 7.3 | consumer | active | 1 | - | -| [req-rs-004](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-004) | MUST | 7.5 | consumer | active | 1 | - | -| [req-rs-005](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-005) | MUST | 7.6 | consumer | active | 1 | - | -| [req-rs-006](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-006) | MUST | 7.2 | consumer | active | 1 | - | -| [req-rs-007](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-007) | MUST | 7.3 | consumer | active | 1 | - | -| [req-rs-008](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-008) | MUST | 7.1 | consumer | active | 7 | - | -| [req-rs-009](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-009) | MUST | 7.5.1 | consumer | active | 1 | - | -| [req-rs-010](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-010) | MUST | 7.2 | consumer | active | 1 | - | -| [req-rs-011](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-011) | MUST | 7.7 | consumer | active | 1 | - | -| [req-rs-012](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-012) | MUST | 7.7 | consumer | active | 1 | - | -| [req-rs-013](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-013) | MUST | 7.2 | consumer | active | 1 | - | -| [req-rs-014](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-014) | MUST | 7.3.1 | consumer | active | 1 | - | -| [req-rs-015](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-015) | MUST | 7.5 | consumer | active | 1 | - | -| [req-rs-016](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-016) | MUST | 7.2 | consumer | active | 7 | - | -| [req-rs-017](docs/src/content/docs/specs/openapm-v0.1.md#req-rs-017) | MUST | 7.7 | consumer | active | 15 | - | -| [req-sc-001](docs/src/content/docs/specs/openapm-v0.1.md#req-sc-001) | MUST | 10.4 | consumer | active | 2 | - | -| [req-sc-002](docs/src/content/docs/specs/openapm-v0.1.md#req-sc-002) | MUST | 10.9 | consumer | active | 1 | - | -| [req-sc-003](docs/src/content/docs/specs/openapm-v0.1.md#req-sc-003) | MUST | 10.3 | consumer | active | 1 | - | -| [req-sc-004](docs/src/content/docs/specs/openapm-v0.1.md#req-sc-004) | MUST | 10.5 | consumer | active | 1 | - | -| [req-sc-005](docs/src/content/docs/specs/openapm-v0.1.md#req-sc-005) | MUST | 10.3 | consumer | active | 1 | - | -| [req-sc-006](docs/src/content/docs/specs/openapm-v0.1.md#req-sc-006) | MUST | 4.2.3 | consumer | active | 1 | - | -| [req-sc-007](docs/src/content/docs/specs/openapm-v0.1.md#req-sc-007) | MUST | 10.3 | consumer | active | 1 | - | -| [req-sc-008](docs/src/content/docs/specs/openapm-v0.1.md#req-sc-008) | SHOULD | 10.3 | consumer | active | 1 | - | -| [req-sc-009](docs/src/content/docs/specs/openapm-v0.1.md#req-sc-009) | MUST | 10.13 | consumer | active | 1 | - | -| [req-sc-010](docs/src/content/docs/specs/openapm-v0.1.md#req-sc-010) | MUST | 10.13 | consumer | active | 1 | - | -| [req-sc-011](docs/src/content/docs/specs/openapm-v0.1.md#req-sc-011) | MUST | 10.14 | consumer | active | 1 | - | -| [req-sc-012](docs/src/content/docs/specs/openapm-v0.1.md#req-sc-012) | MUST | 10.14 | consumer | active | 1 | - | -| [req-sc-013](docs/src/content/docs/specs/openapm-v0.1.md#req-sc-013) | MUST | 10.3 | consumer | active | 1 | - | -| [req-sc-014](docs/src/content/docs/specs/openapm-v0.1.md#req-sc-014) | MUST | 10.15 | consumer | active | 1 | - | -| [req-sc-015](docs/src/content/docs/specs/openapm-v0.1.md#req-sc-015) | MUST | 10.16 | consumer | active | 3 | tests/fixtures/spec-conformance/source-plan/req-sc-015.json | -| [req-tg-001](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-001) | MUST | 8.4 | consumer | active | 1 | - | -| [req-tg-002](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-002) | MUST | 8.5 | consumer | active | 1 | - | -| [req-tg-003](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-003) | MUST | 8.5 | consumer | active | 1 | - | -| [req-tg-004](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-004) | MUST | 4.2.1 | consumer | active | 1 | - | -| [req-tg-005](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-005) | MUST | 8.5 | consumer | active | 1 | - | -| [req-tg-006](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-006) | MUST | 8.5 | consumer | active | 1 | - | -| [req-tg-007](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-007) | MUST | 8.5 | consumer | active | 1 | - | -| [req-tg-008](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-008) | MUST | 8.5.3 | consumer | active | 1 | - | -| [req-tg-009](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-009) | MUST | 8.5.1 | consumer | active | 1 | - | -| [req-tg-010](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-010) | MUST | 8.5.4 | consumer | active | 1 | - | -| [req-tg-011](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-011) | MUST | 8.5.5 | consumer | active | 2 | - | -| [req-tg-012](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-012) | MUST | 8.5.6 | consumer | active | 1 | - | -| [req-tg-013](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-013) | MUST | 8.5.7 | consumer | active | 7 | - | -| [req-tg-014](docs/src/content/docs/specs/openapm-v0.1.md#req-tg-014) | MUST | 8.5.8 | consumer | active | 1 | - | +| [req-cf-001](docs/src/content/docs/specs/openapm-v0.2.md#req-cf-001) | MUST | 12.5 | consumer | active | 6 | - | +| [req-cf-002](docs/src/content/docs/specs/openapm-v0.2.md#req-cf-002) | MUST | 12.3 | consumer | active | 1 | - | +| [req-ext-001](docs/src/content/docs/specs/openapm-v0.2.md#req-ext-001) | MUST | 4.1 | consumer | active | 1 | - | +| [req-ext-002](docs/src/content/docs/specs/openapm-v0.2.md#req-ext-002) | MUST | 4.1 | producer | active | 1 | - | +| [req-lk-001](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-001) | MUST | 5.1 | consumer | active | 1 | - | +| [req-lk-002](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-002) | MUST | 5.4 | consumer | active | 1 | - | +| [req-lk-003](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-003) | MUST | 5.2 | consumer | active | 2 | - | +| [req-lk-004](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-004) | MUST | 5.4 | consumer | active | 1 | - | +| [req-lk-005](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-005) | MUST | 5.5 | consumer | active | 2 | - | +| [req-lk-006](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-006) | MUST | 5.5 | consumer | active | 1 | - | +| [req-lk-007](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-007) | SHOULD | 5.5 | consumer | active | 1 | - | +| [req-lk-008](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-008) | MUST | 5.6 | consumer | active | 1 | - | +| [req-lk-009](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-009) | MUST | 5.6 | consumer | active | 1 | - | +| [req-lk-010](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-010) | MUST | 5.6 | consumer | active | 1 | - | +| [req-lk-011](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-011) | MUST | 5.2 | consumer | active | 1 | - | +| [req-lk-012](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-012) | MUST | 5.2 | consumer | active | 1 | - | +| [req-lk-013](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-013) | MUST | 5.2 | consumer | active | 2 | - | +| [req-lk-014](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-014) | MUST | 5.2 | consumer | active | 1 | - | +| [req-lk-015](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-015) | MUST | 5.6.4 | consumer | active | 1 | - | +| [req-lk-016](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-016) | MUST | 5.2 | consumer | active | 1 | - | +| [req-lk-017](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-017) | MUST | 5.2 | consumer | active | 1 | - | +| [req-lk-018](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-018) | SHOULD | 5.5 | consumer | active | 1 | - | +| [req-lk-019](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-019) | MUST | 5.2 | consumer | active | 1 | - | +| [req-lk-020](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-020) | MUST | 5.2 | consumer | active | 3 | - | +| [req-lk-021](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-021) | MUST | 5.2 | consumer | active | 2 | - | +| [req-lk-022](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-022) | MUST | 5.2 | consumer | active | 4 | - | +| [req-mf-001](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-001) | MUST | 4.1 | producer | active | 1 | - | +| [req-mf-002](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-002) | MUST | 4.1 | producer | active | 1 | - | +| [req-mf-003](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-003) | MUST | 4.1 | producer | active | 1 | - | +| [req-mf-004](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-004) | SHOULD | 4.1 | producer | active | 1 | - | +| [req-mf-005](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-005) | MUST | 4.2.1 | producer | active | 1 | - | +| [req-mf-006](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-006) | MUST | 4.1 | consumer | active | 1 | - | +| [req-mf-007](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-007) | MUST | 4.3.1 | consumer | active | 1 | - | +| [req-mf-008](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-008) | MUST | 4.3.3 | consumer | active | 1 | - | +| [req-mf-009](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-009) | MUST | 4.3.4 | consumer | active | 1 | - | +| [req-mf-010](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-010) | MUST | 4.3.2 | consumer | active | 1 | - | +| [req-mf-011](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-011) | MUST | 4.3.2 | consumer | active | 1 | - | +| [req-mf-012](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-012) | MUST | 4.3.6 | consumer | active | 1 | - | +| [req-mf-013](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-013) | MUST | 4.5 | consumer | active | 1 | - | +| [req-mf-014](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-014) | MUST | 4.2.3 | producer | active | 1 | - | +| [req-mf-015](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-015) | MUST | 4.2.3 | producer | active | 1 | - | +| [req-mf-016](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-016) | MUST | 4.3.5 | consumer | active | 36 | - | +| [req-mf-017](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-017) | MUST | 4.7 | producer | active | 1 | - | +| [req-mf-018](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-018) | MUST | 4.6.1 | consumer | active | 1 | - | +| [req-mf-019](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-019) | MUST | 4.2.4 | consumer | active | 1 | - | +| [req-mf-020](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-020) | MUST | 4.1 | consumer | active | 1 | - | +| [req-mf-021](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-021) | MUST | 4.8 | producer | active | 1 | - | +| [req-mf-022](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-022) | MUST | 4.3.2 | consumer | active | 2 | - | +| [req-mf-023](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-023) | MUST | 4.5 | consumer | active | 1 | - | +| [req-mf-024](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-024) | MUST | 4.3.2 | consumer | active | 1 | - | +| [req-pl-001](docs/src/content/docs/specs/openapm-v0.2.md#req-pl-001) | MUST | 6.1 | governance | active | 1 | - | +| [req-pl-002](docs/src/content/docs/specs/openapm-v0.2.md#req-pl-002) | MUST | 6.2 | governance | active | 1 | - | +| [req-pl-003](docs/src/content/docs/specs/openapm-v0.2.md#req-pl-003) | MUST | 6.4 | governance | active | 1 | - | +| [req-pl-004](docs/src/content/docs/specs/openapm-v0.2.md#req-pl-004) | MUST | 6.4 | governance | active | 1 | - | +| [req-pl-005](docs/src/content/docs/specs/openapm-v0.2.md#req-pl-005) | MUST | 6.5 | governance | active | 1 | - | +| [req-pl-006](docs/src/content/docs/specs/openapm-v0.2.md#req-pl-006) | MUST | 6.4 | governance | active | 1 | - | +| [req-pl-007](docs/src/content/docs/specs/openapm-v0.2.md#req-pl-007) | MUST | 6.3.1 | governance | active | 1 | - | +| [req-pl-008](docs/src/content/docs/specs/openapm-v0.2.md#req-pl-008) | MUST | 6.3.1 | governance | active | 1 | - | +| [req-pl-009](docs/src/content/docs/specs/openapm-v0.2.md#req-pl-009) | MUST | 6.6 | governance | active | 1 | - | +| [req-pl-010](docs/src/content/docs/specs/openapm-v0.2.md#req-pl-010) | MUST | 6.2 | governance | active | 1 | - | +| [req-pl-011](docs/src/content/docs/specs/openapm-v0.2.md#req-pl-011) | MUST | 6.1.1 | governance | active | 2 | - | +| [req-pl-012](docs/src/content/docs/specs/openapm-v0.2.md#req-pl-012) | MUST | 6.1.1 | governance | active | 1 | - | +| [req-pl-013](docs/src/content/docs/specs/openapm-v0.2.md#req-pl-013) | MUST | 6.8 | governance | active | 1 | - | +| [req-pl-014](docs/src/content/docs/specs/openapm-v0.2.md#req-pl-014) | MUST | 6.8 | governance | active | 1 | - | +| [req-pl-015](docs/src/content/docs/specs/openapm-v0.2.md#req-pl-015) | MUST | 6.3.5 | governance | active | 1 | - | +| [req-pl-016](docs/src/content/docs/specs/openapm-v0.2.md#req-pl-016) | MUST | 6.8 | governance | active | 1 | - | +| [req-pl-017](docs/src/content/docs/specs/openapm-v0.2.md#req-pl-017) | MUST | 6.8 | governance | active | 1 | - | +| [req-pr-001](docs/src/content/docs/specs/openapm-v0.2.md#req-pr-001) | MUST | 8.2 | consumer | active | 1 | - | +| [req-pr-002](docs/src/content/docs/specs/openapm-v0.2.md#req-pr-002) | MUST | 8.3 | consumer | active | 1 | - | +| [req-pr-003](docs/src/content/docs/specs/openapm-v0.2.md#req-pr-003) | MUST | 8.3 | consumer | active | 1 | - | +| [req-pr-004](docs/src/content/docs/specs/openapm-v0.2.md#req-pr-004) | MUST | 7.8 | producer | active | 10 | - | +| [req-pr-005](docs/src/content/docs/specs/openapm-v0.2.md#req-pr-005) | SHOULD | 7.8 | producer | active | 1 | - | +| [req-pr-006](docs/src/content/docs/specs/openapm-v0.2.md#req-pr-006) | MUST | 8.1 | consumer | active | 1 | - | +| [req-pr-007](docs/src/content/docs/specs/openapm-v0.2.md#req-pr-007) | MUST | 8.1 | consumer | active | 1 | - | +| [req-rg-001](docs/src/content/docs/specs/openapm-v0.2.md#req-rg-001) | MUST | 11.3.3 | registry | active | 1 | - | +| [req-rs-001](docs/src/content/docs/specs/openapm-v0.2.md#req-rs-001) | MUST | 7.2 | consumer | active | 1 | - | +| [req-rs-002](docs/src/content/docs/specs/openapm-v0.2.md#req-rs-002) | MUST | 7.3 | consumer | active | 1 | - | +| [req-rs-003](docs/src/content/docs/specs/openapm-v0.2.md#req-rs-003) | MUST | 7.3 | consumer | active | 1 | - | +| [req-rs-004](docs/src/content/docs/specs/openapm-v0.2.md#req-rs-004) | MUST | 7.5 | consumer | active | 1 | - | +| [req-rs-005](docs/src/content/docs/specs/openapm-v0.2.md#req-rs-005) | MUST | 7.6 | consumer | active | 1 | - | +| [req-rs-006](docs/src/content/docs/specs/openapm-v0.2.md#req-rs-006) | MUST | 7.2 | consumer | active | 1 | - | +| [req-rs-007](docs/src/content/docs/specs/openapm-v0.2.md#req-rs-007) | MUST | 7.3 | consumer | active | 1 | - | +| [req-rs-008](docs/src/content/docs/specs/openapm-v0.2.md#req-rs-008) | MUST | 7.1 | consumer | active | 7 | - | +| [req-rs-009](docs/src/content/docs/specs/openapm-v0.2.md#req-rs-009) | MUST | 7.5.1 | consumer | active | 1 | - | +| [req-rs-010](docs/src/content/docs/specs/openapm-v0.2.md#req-rs-010) | MUST | 7.2 | consumer | active | 1 | - | +| [req-rs-011](docs/src/content/docs/specs/openapm-v0.2.md#req-rs-011) | MUST | 7.7 | consumer | active | 1 | - | +| [req-rs-012](docs/src/content/docs/specs/openapm-v0.2.md#req-rs-012) | MUST | 7.7 | consumer | active | 1 | - | +| [req-rs-013](docs/src/content/docs/specs/openapm-v0.2.md#req-rs-013) | MUST | 7.2 | consumer | active | 1 | - | +| [req-rs-014](docs/src/content/docs/specs/openapm-v0.2.md#req-rs-014) | MUST | 7.3.1 | consumer | active | 1 | - | +| [req-rs-015](docs/src/content/docs/specs/openapm-v0.2.md#req-rs-015) | MUST | 7.5 | consumer | active | 1 | - | +| [req-rs-016](docs/src/content/docs/specs/openapm-v0.2.md#req-rs-016) | MUST | 7.2 | consumer | active | 7 | - | +| [req-rs-017](docs/src/content/docs/specs/openapm-v0.2.md#req-rs-017) | MUST | 7.7 | consumer | active | 15 | - | +| [req-sc-001](docs/src/content/docs/specs/openapm-v0.2.md#req-sc-001) | MUST | 10.4 | consumer | active | 2 | - | +| [req-sc-002](docs/src/content/docs/specs/openapm-v0.2.md#req-sc-002) | MUST | 10.9 | consumer | active | 1 | - | +| [req-sc-003](docs/src/content/docs/specs/openapm-v0.2.md#req-sc-003) | MUST | 10.3 | consumer | active | 1 | - | +| [req-sc-004](docs/src/content/docs/specs/openapm-v0.2.md#req-sc-004) | MUST | 10.5 | consumer | active | 1 | - | +| [req-sc-005](docs/src/content/docs/specs/openapm-v0.2.md#req-sc-005) | MUST | 10.3 | consumer | active | 1 | - | +| [req-sc-006](docs/src/content/docs/specs/openapm-v0.2.md#req-sc-006) | MUST | 4.2.3 | consumer | active | 1 | - | +| [req-sc-007](docs/src/content/docs/specs/openapm-v0.2.md#req-sc-007) | MUST | 10.3 | consumer | active | 1 | - | +| [req-sc-008](docs/src/content/docs/specs/openapm-v0.2.md#req-sc-008) | SHOULD | 10.3 | consumer | active | 1 | - | +| [req-sc-009](docs/src/content/docs/specs/openapm-v0.2.md#req-sc-009) | MUST | 10.13 | consumer | active | 1 | - | +| [req-sc-010](docs/src/content/docs/specs/openapm-v0.2.md#req-sc-010) | MUST | 10.13 | consumer | active | 1 | - | +| [req-sc-011](docs/src/content/docs/specs/openapm-v0.2.md#req-sc-011) | MUST | 10.14 | consumer | active | 1 | - | +| [req-sc-012](docs/src/content/docs/specs/openapm-v0.2.md#req-sc-012) | MUST | 10.14 | consumer | active | 1 | - | +| [req-sc-013](docs/src/content/docs/specs/openapm-v0.2.md#req-sc-013) | MUST | 10.3 | consumer | active | 1 | - | +| [req-sc-014](docs/src/content/docs/specs/openapm-v0.2.md#req-sc-014) | MUST | 10.15 | consumer | active | 1 | - | +| [req-sc-015](docs/src/content/docs/specs/openapm-v0.2.md#req-sc-015) | MUST | 10.16 | consumer | active | 3 | tests/fixtures/spec-conformance/source-plan/req-sc-015.json | +| [req-tg-001](docs/src/content/docs/specs/openapm-v0.2.md#req-tg-001) | MUST | 8.4 | consumer | active | 1 | - | +| [req-tg-002](docs/src/content/docs/specs/openapm-v0.2.md#req-tg-002) | MUST | 8.5 | consumer | active | 1 | - | +| [req-tg-003](docs/src/content/docs/specs/openapm-v0.2.md#req-tg-003) | MUST | 8.5 | consumer | active | 1 | - | +| [req-tg-004](docs/src/content/docs/specs/openapm-v0.2.md#req-tg-004) | MUST | 4.2.1 | consumer | active | 1 | - | +| [req-tg-005](docs/src/content/docs/specs/openapm-v0.2.md#req-tg-005) | MUST | 8.5 | consumer | active | 1 | - | +| [req-tg-006](docs/src/content/docs/specs/openapm-v0.2.md#req-tg-006) | MUST | 8.5 | consumer | active | 1 | - | +| [req-tg-007](docs/src/content/docs/specs/openapm-v0.2.md#req-tg-007) | MUST | 8.5 | consumer | active | 1 | - | +| [req-tg-008](docs/src/content/docs/specs/openapm-v0.2.md#req-tg-008) | MUST | 8.5.3 | consumer | active | 1 | - | +| [req-tg-009](docs/src/content/docs/specs/openapm-v0.2.md#req-tg-009) | MUST | 8.5.1 | consumer | active | 1 | - | +| [req-tg-010](docs/src/content/docs/specs/openapm-v0.2.md#req-tg-010) | MUST | 8.5.4 | consumer | active | 1 | - | +| [req-tg-011](docs/src/content/docs/specs/openapm-v0.2.md#req-tg-011) | MUST | 8.5.5 | consumer | active | 2 | - | +| [req-tg-012](docs/src/content/docs/specs/openapm-v0.2.md#req-tg-012) | MUST | 8.5.6 | consumer | active | 1 | - | +| [req-tg-013](docs/src/content/docs/specs/openapm-v0.2.md#req-tg-013) | MUST | 8.5.7 | consumer | active | 7 | - | +| [req-tg-014](docs/src/content/docs/specs/openapm-v0.2.md#req-tg-014) | MUST | 8.5.8 | consumer | active | 1 | - | ## Waivers @@ -161,5 +164,5 @@ All four conformance classes (Producer, Consumer, Registry, Governance) carry ac - CONFORMANCE.{md,json} not yet generated in this checkout. Run `uv run python -m tests.spec_conformance.gen_statement`. ### req-lk-018 -- Publish-timestamp recording is a publisher-side SHOULD that requires registry interaction to exercise end-to-end. The schema affordance (generated_at) is asserted above; full publisher coverage requires the registry wire conformance module which is not in v0.1 scope. +- Publish-timestamp recording is a publisher-side SHOULD that requires registry interaction to exercise end-to-end. The schema affordance (generated_at) is asserted above; full publisher coverage requires the registry wire conformance module which remains outside this revision's scope. 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 9d6e92b032..8e27cf8939 100644 --- a/docs/public/specs/manifests/openapm-v0.1.requirements.yml +++ b/docs/public/specs/manifests/openapm-v0.1.requirements.yml @@ -87,8 +87,6 @@ requirements: keyword: MUST section: "4.3.5" conformance_class: consumer - fixture: tests/fixtures/spec-conformance/manifest/valid-local-parent.yml - notes: "Source anchoring and scope admission are distinct from remote-repository and internal local-symlink containment; executable positive and negative cases replace the absolute-path waiver (#2818)." - id: req-mf-017 keyword: MUST section: "4.7" 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..5b3e7b48df --- /dev/null +++ b/docs/public/specs/manifests/openapm-v0.2.requirements.yml @@ -0,0 +1,534 @@ +# OpenAPM v0.2.0 -- informative requirements manifest (draft). +# +# Generated initially by scripts/bootstrap_requirements.py from +# Appendix C of the previous minor, then maintained by hand on every +# spec edit. The orphan_check.py CI gate enforces that this file +# stays an ID/keyword/section/class projection of the HTML anchors in the spec +# body (the canonical source); Appendix C is a reader-aid derived +# from the same anchors. +# +# Do NOT edit this file alone. The three-step ritual for adding a +# normative requirement 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). +# CI fails at any incomplete step. +# +# Informative 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. Audit-owner clause still 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 + fixture: tests/fixtures/spec-conformance/manifest/valid-local-parent.yml + notes: "Clauses (a)-(d) bind source anchoring, user-scope admission, remote-repository containment, and local-content symlinks in test_local_path_reqs.py. Remote _local/ coordinates do not establish local provenance. These bindings assess v0.2.0, not the previous minor's blanket project-root refusal." + - 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-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-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-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/src/content/docs/specs/conformance.md b/docs/src/content/docs/specs/conformance.md index c0b10e1a05..a5288b1607 100644 --- a/docs/src/content/docs/specs/conformance.md +++ b/docs/src/content/docs/specs/conformance.md @@ -1,20 +1,20 @@ --- title: Conformance statement -description: The conformance statement for the APM CLI's implementation of OpenAPM v0.1, generated on every build. +description: The APM CLI's version-qualified OpenAPM requirement bindings and their limits. sidebar: order: 2 --- -This page is the entry point to the conformance statement APM publishes for [OpenAPM v0.1](/apm/specs/openapm-v01/). Per req-cf-002, a conformance statement MUST cite the test invocation per requirement. APM's statement is regenerated by the spec-conformance CI gate when relevant spec, fixture, test, implementation, or statement files change. +The active assessment selects the [OpenAPM v0.2.0 corrective draft](../openapm-v020/). Its generated inventory binds requirements to collected tests; it is not a runtime pass certificate or a claim of ratification. The [previous minor](../openapm-v01/) remains available with its original contract. ## Where the statement lives Two artifacts ship at the repository root and update when the conformance inputs change: -- [`CONFORMANCE.md`](https://github.com/microsoft/apm/blob/main/CONFORMANCE.md) -- human-readable per-requirement table (pass / skip / fail), with a link to the pytest source for each row. Read this first. -- [`CONFORMANCE.json`](https://github.com/microsoft/apm/blob/main/CONFORMANCE.json) -- machine-readable equivalent for tooling and downstream conformance aggregators. The fields are stable across patch versions of the spec. +- [`CONFORMANCE.md`](https://github.com/microsoft/apm/blob/main/CONFORMANCE.md) -- per-requirement static bindings (`active`, `skipped`, `xfail`, or `unbound`), requirement links, and waiver rationale. +- [`CONFORMANCE.json`](https://github.com/microsoft/apm/blob/main/CONFORMANCE.json) -- the same inventory with collected test node IDs, exact specification identity, and input fingerprints. -The CI workflow that emits both is [`.github/workflows/spec-conformance.yml`](https://github.com/microsoft/apm/blob/main/.github/workflows/spec-conformance.yml). The gate fails the build if the generated statement drifts from the committed copy, so the version of `CONFORMANCE.md` you see on `main` is exactly what CI produced from the matching spec commit. +The [spec-conformance workflow](https://github.com/microsoft/apm/blob/main/.github/workflows/spec-conformance.yml) compares regenerated artifacts with the committed copies. Selection is owned by `tests/spec_conformance/_manifest.py`; it validates the manifest identity against the specification artifact. Generation always collects the full active suite afresh and rejects failed collection or mismatched fingerprints instead of reusing an older map. ## How to verify yourself @@ -28,10 +28,12 @@ uv run --extra dev python -m tests.spec_conformance.gen_statement git diff -- CONFORMANCE.md CONFORMANCE.json # compare to the in-repo copies ``` -The first command runs every requirement-bound test and reports per-requirement results. The second regenerates the statement from those results; the final command shows whether the generated artifacts still match the checked-in copies. +The pytest invocation executes the tests and reports test outcomes. The generator separately collects static bindings; it does not consume those execution outcomes. Retain the execution log and exact source or build pin as separate evidence. The final command compares generated artifacts with their committed copies. ## What conformance does NOT cover -Per req-cf-001, a conformance statement covers the normative statements declared in the spec; it does not certify implementation quality, performance, or operational fitness. The statement currently records `req-mf-016` as skipped, with rationale in the statement itself -- an explicit, audited decision rather than silent drift. +An `active` binding does not establish that a test ran or passed. Some bindings inspect schema or specification text rather than running a full lifecycle. Full conformance still needs the evidence and limitations required by Section 11.2 and req-cf-002; source-level results do not substitute for hosted-runtime evidence. + +Current local-source cases assess the corrective req-mf-016 only. They do not prove that the CLI ever satisfied the previous minor's blanket project-root refusal. Preserving that artifact does not claim that the current suite proves historical compliance. The requirements manifest remains informative, existing wire-schema identities are unchanged, and the `latest` citation is not advanced during draft preparation. For the broader drift-detection story (how the spec authors prevent the spec from rotting relative to the only implementation), see [`CONTRIBUTING.md` -- Spec amendment workflow](https://github.com/microsoft/apm/blob/main/CONTRIBUTING.md#spec-amendment-workflow). diff --git a/docs/src/content/docs/specs/openapm-v0.1.md b/docs/src/content/docs/specs/openapm-v0.1.md index 494909b8fd..7822f4c1be 100644 --- a/docs/src/content/docs/specs/openapm-v0.1.md +++ b/docs/src/content/docs/specs/openapm-v0.1.md @@ -221,8 +221,6 @@ type"), the definition section is cross-linked. | **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). | @@ -623,84 +621,10 @@ selection is configured per project. **[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. A local-looking path or a recorded path string -alone is not proof that its parent is local. 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. - -**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. +`.\`, `..\`, or `~\` as local-path entries. The resolver MUST refuse +any local-path entry whose normalised form contains `..` segments +that would escape the project root, with a diagnostic naming the +offending path. #### 4.3.6 MCP dependencies @@ -3917,61 +3841,29 @@ renumbering of conformance classes. **Total normative statements: 121** (116 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 -### Proposed local-path correction (2026-09-06) - -Amendment identifier **0.1.40 (proposed)** tracks the focused Mode C -stale-spec correction in -[microsoft/apm#2818](https://github.com/microsoft/apm/issues/2818), -drafted in [microsoft/apm#2820](https://github.com/microsoft/apm/pull/2820). -It replaces [req-mf-016](#req-mf-016)'s blanket consumer-root escape -rejection with declaring-source anchoring, trusted local sibling -admission, remote-repository containment, and internal local-symlink -containment. It adds executable positive and negative conformance -cases in place of the skipped absolute-path-rejection claim. -The technical review fold clarifies remote-before-local admission, -adds user-scope remote, multihop, and missing-anchor controls, and -records the limits of local replay interoperability. -Statement count remains **121** (116 MUST, 5 SHOULD); no requirement -identifier is added, removed, or renumbered, and no file schema changes. - -**Classification and compatibility.** This is a substantive normative -correction, not typo errata. It removes a false universal -consumer-root containment claim without requiring every consumer to -admit every local source root. A consumer that restricts local roots -may retain that restriction as documented policy, with honest -rejection diagnostics; a consumer that admits a reference is bound -to the specified source anchor and containment rules. Existing -trusted-local sibling consumers retain their source-selection -behavior. Implementers should distinguish permitted source roots -from the required anchoring and containment of admitted sources. -The explicit remote and internal-symlink obligations also need -compatibility review; this amendment does not assume that every -previously conforming implementation already enforced them. The proposed -amendment identifier does not itself decide whether the compatibility -impact requires a new minor revision and migration window under -[Section 9.2](#92-breaking-vs-non-breaking-change-definition), -[Section 9.4](#94-errata-vs-new-revision), and -[Section 9.5](#95-migration-windows-for-consumers). That classification -remains part of the non-author review and maintainer ratification. -No versioning or migration exception is claimed. - -**One-amendment process exception.** On 2026-09-06 the maintainer -authorized this local-path correction without the 14-day public -comment period in Section 9.3 step 4. The exception applies only to -this amendment in #2820. The required non-author review, conformance -evidence, and ratification remain; Section 9 and the process for -future amendments are unchanged. This draft does not record panel -approval or a completed ratification. + +### Corrective v0.2.0 preparation notice (informative) + +A separate [OpenAPM v0.2.0 draft](../openapm-v020/) is being prepared +for the local-source provenance/anchoring and audit current-intent/read-only +corrections. Its changed req-mf-016 and new audit obligation apply only +to that separately versioned contract. This notice does not modify this +minor's operative requirements, requirements manifest, schemas, or +conformance interpretation. This minor remains available and supported +indefinitely; no removal date is set. + +Prepared on 2026-09-06; this is not an announcement or publication date. +The publication owner will record the actual announcement and review/ +ratification evidence when publication occurs. Section 9.5's 90-day +minimum is between announcement and removal, not between announcement +and parallel publication. No migration exception is claimed. The +maintainer waived only the public-comment period for the corrective +publication; this notice does not assert completed review or ratification. + | Version | Date | Changes | |---------|------------|----------------------------------------------------------| 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..fad2e54082 --- /dev/null +++ b/docs/src/content/docs/specs/openapm-v0.2.md @@ -0,0 +1,4085 @@ +--- +title: OpenAPM v0.2.0 +description: Normative specification for the Agent Package Manager (APM) format and conformance. +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**, prepared on **2026-09-06**. This is a preparation date, not an +announcement, publication, or ratification date. Review and ratification +remain pending. Citing this draft as a ratified specification is inappropriate. + +This bounded corrective minor changes only local-source provenance and +anchoring ([req-mf-016](#req-mf-016)) and the current-intent, read-only +audit contract in Section 5.5. The audit-owner wording is pending; this +draft is not a publishable candidate until that input is integrated. +Features previously reserved for v0.2 remain reserved for a future +revision; they are not activated by v0.2.0. + +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 draft carries **121 normative statements** indexed in + [Appendix C](#appendix-c-index-of-normative-statements). +- All on-disk files defined by this specification are **YAML 1.2** + parsed under the safe subset defined in + [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 alias. | +| `skills` | no | Skill-subset selection for dependencies that expose selectable skills (see [Section 8.1](#81-primitive-types)). | + + +**[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. + +**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). +- Producer (SHOULD): [req-mf-004](#req-mf-004). +- Consumer: [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-ext-001](#req-ext-001), + [req-ext-002](#req-ext-002), + [req-tg-004](#req-tg-004), [req-sc-006](#req-sc-006). + +--- + +## 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). | +| `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 is the contract `apm audit` validates the workspace +against. + + +**[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. + +> **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, with `tree_sha256` providing collision-resistant +> integrity. 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). +- 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 `/`. Tri-state (see [Section 6.5](#65-allow-list--deny-list-tri-state-semantics)). | +| `deny` | Always wins over `allow`. | +| `require` | Packages every consumer manifest must include. | +| `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. + +#### 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 are matched with the same pattern semantics as the policy +allow-list and deny-list fields (see +[Section 6.5](#65-allow-list--deny-list-tri-state-semantics)). + + +**[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. `null` is transparent. | +| `*.deny` lists | Union, deduplicated, parent order preserved. | +| `*.require` lists | Union, deduplicated, 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`). | + +### 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). + +### 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`, detected drift +MUST be reported without, by itself, altering the audit exit status. + + +**[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). + +--- + +## 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 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. 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) + +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 ` or via the manifest's `target:` field. 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. + +**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-006](#req-sc-006)), 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. + +### 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. + +### 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); 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) | 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. + +### 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 that restate the Appendix C rows for the section's +class. Appendix C is the canonical source of truth; on any +conflict between a section summary and Appendix C, Appendix C +wins. + +### 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). + +### 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). + +#### 11.3.2 Consumer + +[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-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-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). + +### 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 uses to detect silent drift between the +canonical spec anchors, the Appendix C reader-aid table, and the +test marker coverage. 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. Three 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. | + +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. 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 machine-readable schemas backing this specification are +reused unchanged from the previous minor and are normative. This corrective +revision introduces no wire-schema change; schema identities do not follow +the specification's version number. + +The unchanged wire-schema `$id` values are: + +- `https://microsoft.github.io/apm/specs/schemas/manifest-v0.1.schema.json` +- `https://microsoft.github.io/apm/specs/schemas/lockfile-v0.1.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.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). | +| 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 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. + +--- + +## 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-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-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-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: 121** (116 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 revision (prepared 2026-09-06; 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). +Only those two contracts change behavior. The approved local-source +clauses replace [req-mf-016](#req-mf-016)'s previous blanket project-root +escape rejection with source-provenance admission, original declaring-source +anchors, remote-repository containment, and internal local-symlink rules. +The audit clause is awaiting its owner's final wording and evidence. + +**Classification and preservation.** These are substantive changes to +the conformance contract under Sections 9.1, 9.2, and 9.4, not same-minor +errata. The previous minor's operative specification, requirements +manifest, schemas, and conformance interpretation are retained from +`0ae9a78c6129e41caf87cb75130f9006b31ed98d`; only an explicitly delimited +informative announcement is added to its Appendix D. That previous +minor remains 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 needed or claimed. + +An admitted local sibling outside the project root can satisfy this +revision while violating the old blanket rule. Neither this correction +nor the new 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 for this corrective +publication. Qualifying non-author review, implementation evidence, and +ratification remain required. This preparation record does not assert +that any of them is complete. The publication owner records actual +announcement/publication dates and approval evidence at publication; +2026-09-06 above is only the preparation date. Section 9 remains unchanged. + +No previously reserved workspace, nesting, attestation, HTTP wire, +internationalization, range-widening, withdrawal, or default-frozen feature +is activated. Existing schema identities and independently versioned +companions are reused without alteration. + +### 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). | + +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/scripts/architecture_linter/checks/contracts_test_taxonomy.py b/scripts/architecture_linter/checks/contracts_test_taxonomy.py index d904011cdf..fc5abb2e83 100644 --- a/scripts/architecture_linter/checks/contracts_test_taxonomy.py +++ b/scripts/architecture_linter/checks/contracts_test_taxonomy.py @@ -83,6 +83,48 @@ _GUARD_GENERATION_FOOTER = "contracts-tooling-generation-footer" +_GUARD_SPEC_ASSESSMENT = "contracts-tooling-spec-assessment" + + +def check_spec_assessment_authority(provider: FactsProvider) -> tuple[Violation, ...]: + """Keep selected artifact, exact identity, and collection decisions in one owner.""" + required_calls = { + "tests/spec_conformance/_helpers.py": ("selected_assessment(",), + "tests/spec_conformance/conftest.py": ("coverage_document(", "coverage_output_path("), + "tests/spec_conformance/orphan_check.py": ("collect_coverage(",), + "tests/spec_conformance/gen_statement.py": ("collect_coverage(", "selected_assessment("), + "tests/spec_conformance/mode_b_detector.sh": ("-m tests.spec_conformance._manifest",), + } + findings: list[Violation] = [] + for path, calls in required_calls.items(): + facts, failures = _facts_for(provider, path, _GUARD_SPEC_ASSESSMENT) + findings.extend(failures) + if failures: + continue + if any(not _present(facts, call) for call in calls): + findings.append( + _summary( + _GUARD_SPEC_ASSESSMENT, + path, + "Spec assessment consumers must route through tests/spec_conformance/_manifest.py", + ) + ) + if _present_re( + facts, + re.compile( + r"""(?:openapm-v[0-9]+\.[0-9]+|["']v[0-9]+\.[0-9]+\.[0-9]+["']""" + r"|^\s*(?:SPEC_PATH|MANIFEST_PATH|SPEC_VERSION|_SELECTED_MINOR)\s*=)" + ), + ): + findings.append( + _summary( + _GUARD_SPEC_ASSESSMENT, + path, + "Selected spec paths and exact revision must not be independently hard-coded", + ) + ) + return tuple(findings) + _SRC_PREFIX = "src/apm_cli/" @@ -1292,6 +1334,11 @@ def _structural_rule(rule_id: str, description: str, check) -> Rule: RULES: tuple[Rule, ...] = ( + _owner_rule( + _GUARD_SPEC_ASSESSMENT, + "Selected specification and fresh binding inventory have one canonical owner.", + check_spec_assessment_authority, + ), _owner_rule( _GUARD_TAXONOMY, "Behavioral test taxonomy classification stays owned by module-level pytestmark.", diff --git a/tests/integration/test_architecture_owner_rule_mutations.py b/tests/integration/test_architecture_owner_rule_mutations.py index 8089496ef9..8510f3b36b 100644 --- a/tests/integration/test_architecture_owner_rule_mutations.py +++ b/tests/integration/test_architecture_owner_rule_mutations.py @@ -189,6 +189,14 @@ class MutationCase: new="def _hand_authored_root_context_blocks_write_disabled(", intent="Root context writes lose the canonical hand-authored ownership gate.", ), + MutationCase( + guard_id="contracts-tooling-spec-assessment", + rule_id="contracts-tooling-spec-assessment", + path="tests/spec_conformance/_helpers.py", + old="selected_assessment().spec_path", + new="local_assessment().spec_path", + intent="Spec-text helper stops using the canonical selected assessment.", + ), MutationCase( guard_id="hooks-integrations-copilot-cli-mcp-paths", rule_id="mutation_writes.copilot_cli_mcp_paths", diff --git a/tests/spec_conformance/__init__.py b/tests/spec_conformance/__init__.py index 84f2137bfc..72e0089fa7 100644 --- a/tests/spec_conformance/__init__.py +++ b/tests/spec_conformance/__init__.py @@ -1,6 +1,6 @@ """Conformance test package marker. -Tests under this package bind to OpenAPM v0.1 normative requirements +Tests under this package bind to the one selected OpenAPM revision's requirements via `@pytest.mark.req("req-XXX")`. The orphan_check gate and gen_statement reader live as siblings. """ diff --git a/tests/spec_conformance/_helpers.py b/tests/spec_conformance/_helpers.py index 9dd54b8cb5..01c9762ff0 100644 --- a/tests/spec_conformance/_helpers.py +++ b/tests/spec_conformance/_helpers.py @@ -10,7 +10,7 @@ matching test breaks at PR time. There is also `waive(...)`. Use it ONLY when the requirement is -genuinely beyond v0.1 active testability and the rationale is +genuinely beyond the selected revision's active testability and the rationale is written down. Every waiver appears in CONFORMANCE.md as debt. """ @@ -25,7 +25,7 @@ import yaml from jsonschema import Draft202012Validator -from tests.spec_conformance._manifest import FIXTURE_ROOT, SPEC_DIR, SPEC_PATH +from tests.spec_conformance._manifest import FIXTURE_ROOT, SPEC_DIR, selected_assessment def waive(reason: str) -> None: @@ -67,7 +67,7 @@ def fixture_path(*parts: str) -> Path: def spec_text() -> str: global _SPEC_TEXT_CACHE if _SPEC_TEXT_CACHE is None: - _SPEC_TEXT_CACHE = SPEC_PATH.read_text(encoding="utf-8") + _SPEC_TEXT_CACHE = selected_assessment().spec_path.read_text(encoding="utf-8") return _SPEC_TEXT_CACHE diff --git a/tests/spec_conformance/_manifest.py b/tests/spec_conformance/_manifest.py index 31eed3310f..ac044d45d2 100644 --- a/tests/spec_conformance/_manifest.py +++ b/tests/spec_conformance/_manifest.py @@ -6,9 +6,15 @@ from __future__ import annotations +import hashlib import json +import os +import re +import subprocess +import sys from dataclasses import dataclass from pathlib import Path +from tempfile import TemporaryDirectory from typing import Any import yaml @@ -16,20 +22,49 @@ REPO_ROOT = Path(__file__).resolve().parents[2] SPEC_DIR = REPO_ROOT / "docs" / "src" / "content" / "docs" / "specs" -SPEC_PATH = SPEC_DIR / "openapm-v0.1.md" +_SELECTED_MINOR = "v0.2" +SPEC_PATH = SPEC_DIR / f"openapm-{_SELECTED_MINOR}.md" # Schemas and the requirements manifest are served as static assets so # the schema $id URLs resolve on the published site. They live under # docs/public/specs/, not under the Starlight content collection. PUBLIC_SPEC_DIR = REPO_ROOT / "docs" / "public" / "specs" -MANIFEST_PATH = PUBLIC_SPEC_DIR / "manifests" / "openapm-v0.1.requirements.yml" +MANIFEST_PATH = PUBLIC_SPEC_DIR / "manifests" / f"openapm-{_SELECTED_MINOR}.requirements.yml" SCHEMA_PATH = PUBLIC_SPEC_DIR / "schemas" / "requirements-v0.1.schema.json" FIXTURE_ROOT = REPO_ROOT / "tests" / "fixtures" / "spec-conformance" -COVERAGE_PATH = REPO_ROOT / "build" / "conformance-coverage.json" +COVERAGE_ENV = "APM_CONFORMANCE_COVERAGE_PATH" +Coverage = dict[str, list[dict[str, str]]] ALLOWED_KEYWORDS = ("MUST", "MUST NOT", "SHOULD", "SHOULD NOT", "MAY") ALLOWED_CLASSES = ("producer", "consumer", "registry", "governance") +@dataclass(frozen=True) +class Assessment: + """The one active exact revision, with its validated input fingerprints.""" + + version: str + spec_path: Path + manifest_path: Path + spec_sha256: str + manifest_sha256: str + + @property + def citation(self) -> str: + return f"https://microsoft.github.io/apm/spec/{self.version}" + + @property + def coverage_path(self) -> Path: + return REPO_ROOT / "build" / f"conformance-coverage-{self.version}.json" + + def stamp(self) -> dict[str, str]: + return { + "spec_version": self.version, + "spec_sha256": self.spec_sha256, + "manifest_sha256": self.manifest_sha256, + "inventory_kind": "static-test-bindings", + } + + @dataclass(frozen=True) class Requirement: id: str @@ -54,7 +89,109 @@ def load_manifest_raw() -> dict[str, Any]: return yaml.safe_load(f) +def selected_assessment() -> Assessment: + """Validate metadata against the artifact, without a second version authority.""" + raw = load_manifest_raw() + Draft202012Validator(load_schema()).validate(raw) + version = raw["spec_version"] + if not re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+", version): + raise ValueError("Selected assessment requires an exact specification revision") + if version.rsplit(".", 1)[0] != _SELECTED_MINOR: + raise ValueError("Selected manifest revision disagrees with its minor artifact") + text = SPEC_PATH.read_text(encoding="utf-8") + parts = text.split("---\n", 2) + if len(parts) != 3 or parts[0]: + raise ValueError("Selected specification has no frontmatter identity") + metadata = yaml.safe_load(parts[1]) + if not isinstance(metadata, dict) or metadata.get("title") != f"OpenAPM {version}": + raise ValueError("Selected specification identity disagrees with manifest metadata") + if metadata.get("slug") != f"specs/openapm-{version.replace('.', '')}": + raise ValueError("Selected specification needs an exact-revision content route") + return Assessment( + version=version, + spec_path=SPEC_PATH, + manifest_path=MANIFEST_PATH, + spec_sha256=hashlib.sha256(SPEC_PATH.read_bytes()).hexdigest(), + manifest_sha256=hashlib.sha256(MANIFEST_PATH.read_bytes()).hexdigest(), + ) + + +def coverage_document(coverage: Coverage) -> dict[str, Any]: + """Stamp static bindings, not runtime outcomes, with the selected inputs.""" + return {**selected_assessment().stamp(), "requirements": coverage} + + +def coverage_output_path() -> Path: + """Honor the fresh collector's isolated output, or use a version-qualified map.""" + requested = os.environ.get(COVERAGE_ENV) + return Path(requested) if requested else selected_assessment().coverage_path + + +def load_coverage(path: Path) -> Coverage: + """Reject stale, unversioned, mismatched, or malformed binding inventories.""" + with path.open(encoding="utf-8") as handle: + document = json.load(handle) + if not isinstance(document, dict) or any( + document.get(key) != value for key, value in selected_assessment().stamp().items() + ): + raise ValueError("Coverage identity or input fingerprints do not match the assessment") + coverage = document.get("requirements") + if not isinstance(coverage, dict) or any( + not isinstance(req_id, str) + or not isinstance(rows, list) + or any( + not isinstance(row, dict) + or not isinstance(row.get("test_nodeid"), str) + or row.get("status") not in {"active", "skipped", "xfail"} + for row in rows + ) + for req_id, rows in coverage.items() + ): + raise ValueError("Malformed conformance binding inventory") + return coverage + + +def collect_coverage() -> Coverage: + """Always collect the full active suite; never reuse a previous map on failure.""" + assessment = selected_assessment() + assessment.coverage_path.parent.mkdir(parents=True, exist_ok=True) + with TemporaryDirectory( + prefix="conformance-", dir=assessment.coverage_path.parent + ) as temporary: + output = Path(temporary) / assessment.coverage_path.name + env = {**os.environ, COVERAGE_ENV: str(output)} + env.pop("PYTEST_ADDOPTS", None) + result = subprocess.run( + [ + sys.executable, + "-m", + "pytest", + "tests/spec_conformance", + "--collect-only", + "-q", + "-o", + "addopts=", + "-p", + "no:randomly", + "--no-header", + ], + cwd=REPO_ROOT, + env=env, + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise RuntimeError( + f"Full spec collection failed ({result.returncode}):\n" + f"{result.stdout}\n{result.stderr}" + ) + if not output.is_file(): + raise RuntimeError("Full spec collection produced no binding inventory") + return load_coverage(output) + + def load_requirements() -> list[Requirement]: + selected_assessment() schema = load_schema() raw = load_manifest_raw() Draft202012Validator(schema).validate(raw) @@ -77,3 +214,9 @@ def load_requirements() -> list[Requirement]: def requirements_by_id() -> dict[str, Requirement]: return {r.id: r for r in load_requirements()} + + +if __name__ == "__main__": + selection = selected_assessment() + print(selection.spec_path.relative_to(REPO_ROOT).as_posix()) + print(selection.manifest_path.relative_to(REPO_ROOT).as_posix()) diff --git a/tests/spec_conformance/conftest.py b/tests/spec_conformance/conftest.py index 317355e532..406a4f1588 100644 --- a/tests/spec_conformance/conftest.py +++ b/tests/spec_conformance/conftest.py @@ -2,8 +2,8 @@ Registers and enforces the `req` marker. Every marker MUST resolve to an id in the requirements manifest; unknown ids fail collection. The -marker coverage map is written to build/conformance-coverage.json for -gen_statement.py consumption. +static binding map is version-qualified and stamped by the shared +assessment owner. Statement generation always requests fresh full collection. """ from __future__ import annotations @@ -17,8 +17,9 @@ import pytest from tests.spec_conformance._manifest import ( - COVERAGE_PATH, REPO_ROOT, + coverage_document, + coverage_output_path, requirements_by_id, ) @@ -100,12 +101,13 @@ def pytest_collection_modifyitems(config, items: list[pytest.Item]) -> None: if errors: joined = "\n".join(f" - {e}" for e in errors) raise pytest.UsageError("Spec-conformance marker validation failed:\n" + joined) - COVERAGE_PATH.parent.mkdir(parents=True, exist_ok=True) + output = coverage_output_path() + output.parent.mkdir(parents=True, exist_ok=True) canonical = { rid: sorted(rows, key=lambda r: r["test_nodeid"]) for rid, rows in sorted(coverage.items()) } - with COVERAGE_PATH.open("w", encoding="ascii", newline="\n") as f: - json.dump(canonical, f, indent=2, sort_keys=True) + with output.open("w", encoding="ascii", newline="\n") as f: + json.dump(coverage_document(canonical), f, indent=2, sort_keys=True) f.write("\n") diff --git a/tests/spec_conformance/gen_statement.py b/tests/spec_conformance/gen_statement.py index 0a917456c6..88f9a436bf 100644 --- a/tests/spec_conformance/gen_statement.py +++ b/tests/spec_conformance/gen_statement.py @@ -1,8 +1,8 @@ -"""Generate the OpenAPM v0.1 conformance statement. +"""Generate the selected OpenAPM revision's static binding inventory. Reads: - - build/conformance-coverage.json (written by conftest at collection) - - docs/.../openapm-v0.1.requirements.yml (manifest) + - a fresh, version-stamped full-suite collection + - the selected requirements manifest - test source files (for waiver/assertion extraction via ast) Writes: @@ -17,23 +17,23 @@ import ast import json -import subprocess import sys from collections import defaultdict from tests.spec_conformance._manifest import ( ALLOWED_CLASSES, - COVERAGE_PATH, REPO_ROOT, - SPEC_PATH, + Coverage, + collect_coverage, load_requirements, + selected_assessment, ) +from tests.spec_conformance.orphan_check import check_bindings CONFORMANCE_JSON = REPO_ROOT / "CONFORMANCE.json" CONFORMANCE_MD = REPO_ROOT / "CONFORMANCE.md" -SPEC_VERSION = "v0.1.1" -GENERATOR = "gen_statement.py v1" +GENERATOR = "gen_statement.py v2" USER_SCOPE_DISCLOSURE = { "manifest_location": "~/.apm/apm.yml", "lockfile_location": "~/.apm/apm.lock.yaml", @@ -43,29 +43,12 @@ } -def _ensure_coverage() -> dict[str, list[dict[str, str]]]: - if not COVERAGE_PATH.exists(): - res = subprocess.run( - [ - sys.executable, - "-m", - "pytest", - "tests/spec_conformance", - "--collect-only", - "-q", - "-p", - "no:randomly", - "--no-header", - ], - cwd=REPO_ROOT, - capture_output=True, - text=True, - ) - if not COVERAGE_PATH.exists(): - sys.stderr.write(res.stderr) - raise SystemExit(2) - with COVERAGE_PATH.open(encoding="utf-8") as f: - return json.load(f) +def _ensure_coverage() -> Coverage: + """Require fresh collection and exact four-way binding before rendering.""" + coverage = collect_coverage() + if check_bindings(coverage): + raise ValueError("gen_statement refuses to write while the four-way bind fails") + return coverage def _extract_waivers() -> dict[str, list[str]]: @@ -121,6 +104,7 @@ def _aggregate_status(rows: list[dict[str, str]]) -> str: def build_json() -> dict: coverage = _ensure_coverage() + assessment = selected_assessment() waivers = _extract_waivers() reqs = load_requirements() entries = [] @@ -150,7 +134,9 @@ def build_json() -> dict: for c in ALLOWED_CLASSES } return { - "spec_version": SPEC_VERSION, + **assessment.stamp(), + "spec_path": assessment.spec_path.relative_to(REPO_ROOT).as_posix(), + "spec_citation": assessment.citation, "generator": GENERATOR, "total_requirements": len(entries), "summary_by_class": summary, @@ -173,32 +159,37 @@ def _md_class_summary(summary: dict) -> str: def build_md(doc: dict) -> str: preamble = ( - f"# OpenAPM Conformance Statement -- {SPEC_VERSION}\n\n" + f"# OpenAPM Conformance Binding Inventory -- {doc['spec_version']}\n\n" f"Generator: {GENERATOR}.\n" - "Spec: [docs/src/content/docs/specs/openapm-v0.1.md]" - "(docs/src/content/docs/specs/openapm-v0.1.md)\n\n" + f"Spec: [{doc['spec_path']}]({doc['spec_path']})\n" + f"Exact revision citation: {doc['spec_citation']}\n\n" "This file is generated. Do NOT edit by hand. Run\n" "`uv run python -m tests.spec_conformance.gen_statement` to regenerate.\n\n" "## Honesty contract\n\n" "There is NO automated CI detector for spec-vs-behaviour drift " "beyond the four sets enforced by `orphan_check.py`: spec anchors, " "manifest entries, Appendix C rows, and `@pytest.mark.req` markers. " - "A requirement marked `status=active` is exercised by at least one " - "assertion. A requirement marked `status=skipped` carries a written " - "waiver below; this is debt, not coverage. A requirement with " - "`status=xfail` is asserted-but-known-broken.\n\n" + "Statuses are a static binding inventory from fresh full-suite collection, " + "not executed test results or a runtime pass certificate. `status=active` " + "means a collected binding is not statically marked skipped or xfail; " + "it does not prove that an assertion ran or passed. `status=skipped` " + "and `status=xfail` describe static markers or waiver calls, not measured " + "execution outcomes. Waivers are listed below as debt. Separate test " + "execution and implementation evidence remain necessary.\n\n" + "This inventory assesses only the selected corrective revision. It does " + "not establish historical CLI conformance to the previous minor's " + "req-mf-016 blanket project-root refusal. A prepared specification and " + "collected bindings do not establish publication or ratification.\n\n" "## Conformance classes\n\n" - "All four conformance classes (Producer, Consumer, Registry, " - "Governance) carry active coverage in this statement. The " - "Registry class is exercised via the trust-anchor invariant " + "The four conformance classes (Producer, Consumer, Registry, " + "Governance) are inventoried below, not certified by this report. The " + "Registry binding includes the trust-anchor invariant " "test in `tests/spec_conformance/test_registry_reqs.py`, " "which hashes the committed Registry-archive fixture and " "asserts equality with the digest the paired lockfile " "advertises (sec.11.3.3, req-rg-001).\n\n" ) - summary_section = ( - "## Coverage summary\n\n" + _md_class_summary(doc["summary_by_class"]) + "\n\n" - ) + summary_section = "## Binding summary\n\n" + _md_class_summary(doc["summary_by_class"]) + "\n\n" user_scope = doc["consumer_user_scope"] scope_section = ( "## Consumer user-scope disclosure\n\n" @@ -208,13 +199,13 @@ def build_md(doc: dict) -> str: f"`{user_scope['target_capability_declaration']}`\n\n" ) rows = [ - "## Per-requirement coverage\n", + "## Per-requirement bindings\n", "| Req ID | Keyword | Sec | Class | Status | Tests | Oracle |", "|--------|---------|----:|-------|--------|------:|--------|", ] for e in doc["requirements"]: rows.append( - f"| [{e['id']}](docs/src/content/docs/specs/openapm-v0.1.md#{e['id']}) " + f"| [{e['id']}]({doc['spec_path']}#{e['id']}) " f"| {e['keyword']} | {e['section']} | {e['conformance_class']} " f"| {e['status']} | {e['test_count']} | {e.get('oracle', '-')} |" ) @@ -248,26 +239,11 @@ def write_outputs() -> None: def main() -> int: - # F11 honesty: refuse to generate if spec anchors disagree with the - # manifest. The orphan_check is the canonical gate, so we run it. - res = subprocess.run( - [sys.executable, "-m", "tests.spec_conformance.orphan_check"], - cwd=REPO_ROOT, - capture_output=True, - text=True, - ) - if res.returncode != 0: - sys.stderr.write(res.stderr) - sys.stderr.write( - "\n[x] gen_statement refuses to write while orphan_check fails. " - "Fix the 4-way bind first.\n" - ) - return 1 - sanity = SPEC_PATH.read_text(encoding="utf-8").count(' set[str]: def set_markers() -> set[str]: - """Run pytest in collect-only mode and harvest the coverage map. - - We invoke pytest in a sub-process to honour the spec_conformance - conftest's marker-validation step. The coverage map is written by - pytest_collection_modifyitems. - """ - if COVERAGE_PATH.exists(): - COVERAGE_PATH.unlink() - cmd = [ - sys.executable, - "-m", - "pytest", - "tests/spec_conformance", - "--collect-only", - "-q", - "-p", - "no:randomly", - "--no-header", - ] - res = subprocess.run(cmd, cwd=REPO_ROOT, capture_output=True, text=True) - if res.returncode != 0 and not COVERAGE_PATH.exists(): - sys.stderr.write( - "orphan_check: pytest --collect-only failed before producing " - "coverage map. pytest stderr below:\n" - ) - sys.stderr.write(res.stderr) - sys.exit(2) - if not COVERAGE_PATH.exists(): - return set() - with COVERAGE_PATH.open(encoding="utf-8") as f: - data = json.load(f) - return set(data.keys()) + """Collect the complete active assessment through the shared owner.""" + return set(collect_coverage()) def diff_report(label_a: str, set_a: set[str], label_b: str, set_b: set[str]) -> list[str]: @@ -135,11 +104,12 @@ def check_appendix_c_consistency() -> list[str]: return problems -def main() -> int: +def check_bindings(coverage: Coverage) -> int: + """Compare all four projections using an already freshly collected inventory.""" anchors = set_anchors() manifest = set_manifest() appc = set_appc() - markers = set_markers() + markers = set(coverage) failures: list[str] = [] if anchors != manifest: failures.append("[x] anchors != manifest") @@ -170,5 +140,13 @@ def main() -> int: return 0 +def main() -> int: + try: + return check_bindings(collect_coverage()) + except (ValueError, RuntimeError, OSError) as error: + sys.stderr.write(f"[x] orphan_check: {error}\n") + return 2 + + if __name__ == "__main__": raise SystemExit(main()) diff --git a/tests/spec_conformance/test_gen_statement.py b/tests/spec_conformance/test_gen_statement.py index a624981ca0..88ed92bb8e 100644 --- a/tests/spec_conformance/test_gen_statement.py +++ b/tests/spec_conformance/test_gen_statement.py @@ -6,12 +6,11 @@ import subprocess import sys -from tests.spec_conformance._manifest import REPO_ROOT +from tests.spec_conformance._manifest import REPO_ROOT, selected_assessment from tests.spec_conformance.gen_statement import ( CONFORMANCE_JSON, CONFORMANCE_MD, GENERATOR, - SPEC_VERSION, USER_SCOPE_DISCLOSURE, ) @@ -51,7 +50,7 @@ def test_gen_statement_emits_ascii_only(): def test_gen_statement_md_advertises_spec_version_and_generator(): _run_gen() md = CONFORMANCE_MD.read_text(encoding="ascii") - assert SPEC_VERSION in md, f"missing '{SPEC_VERSION}' in CONFORMANCE.md" + assert selected_assessment().version in md assert GENERATOR in md, f"missing '{GENERATOR}' in CONFORMANCE.md" diff --git a/tests/spec_conformance/test_lockfile_reqs.py b/tests/spec_conformance/test_lockfile_reqs.py index b1d7b08273..25cc59ebe5 100644 --- a/tests/spec_conformance/test_lockfile_reqs.py +++ b/tests/spec_conformance/test_lockfile_reqs.py @@ -269,7 +269,7 @@ 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.""" + """The unchanged wire schema and corrective revision retain bare-hex readers.""" schema = load_schema("lockfile-v0.1.schema.json") pattern = schema["properties"]["local_deployed_file_hashes"]["additionalProperties"]["pattern"] assert "[0-9a-f]{64}" in pattern @@ -314,7 +314,7 @@ def test_lockfile_should_record_publish_timestamp(): "requires registry interaction to exercise end-to-end. The " "schema affordance (generated_at) is asserted above; full " "publisher coverage requires the registry wire conformance " - "module which is not in v0.1 scope." + "module which remains outside this revision's scope." ) diff --git a/tests/spec_conformance/test_manifest_reqs.py b/tests/spec_conformance/test_manifest_reqs.py index 692f9321e7..6cec4e6f08 100644 --- a/tests/spec_conformance/test_manifest_reqs.py +++ b/tests/spec_conformance/test_manifest_reqs.py @@ -241,9 +241,13 @@ def test_consumer_resolves_runtime_argument_templates_without_secret_leakage(): @pytest.mark.req("req-mf-021") -def test_producer_workspaces_must_not_use_in_v0_1(): - """req-mf-021 forbids workspaces in v0.1.""" - assert_spec_contains("workspaces", "v0.1") +def test_producer_workspaces_remain_reserved(): + """The corrective revision does not activate the old future-version promise.""" + assert_spec_contains( + "**producer** MUST NOT\ndeclare a top-level `workspaces:` key", + "reserved for a future revision and MUST NOT attach any semantics", + "The diagnostic MUST NOT fail install.", + ) @pytest.mark.req("req-mf-022") diff --git a/tests/spec_conformance/test_mode_b_detector.py b/tests/spec_conformance/test_mode_b_detector.py index 43586f7615..d41bb1325a 100644 --- a/tests/spec_conformance/test_mode_b_detector.py +++ b/tests/spec_conformance/test_mode_b_detector.py @@ -17,11 +17,18 @@ import os import shutil import subprocess +import sys from pathlib import Path import pytest -REPO_ROOT = Path(__file__).resolve().parents[2] +from tests.spec_conformance._manifest import ( + MANIFEST_PATH, + REPO_ROOT, + SCHEMA_PATH, + SPEC_PATH, +) + DETECTOR = REPO_ROOT / "tests" / "spec_conformance" / "mode_b_detector.sh" PATHS_FILE = REPO_ROOT / "tests" / "spec_conformance" / "critical_paths.txt" WORKFLOW = REPO_ROOT / ".github" / "workflows" / "spec-conformance.yml" @@ -74,16 +81,16 @@ def test_critical_paths_file_lists_known_directories(): ) -def test_detector_short_circuits_on_spec_concurrent_edit(tmp_path): +@pytest.mark.parametrize("selected_path", [SPEC_PATH, MANIFEST_PATH]) +def test_detector_short_circuits_on_spec_concurrent_edit(tmp_path, selected_path): """A PR that edits the spec body MUST short-circuit (exit 0).""" repo = _make_repo(tmp_path) # Add a substantive critical-path change AND a spec edit. (repo / "src" / "apm_cli" / "deps" / "new.py").write_text( "\n".join(f"x = {i}" for i in range(40)) + "\n" ) - spec = repo / "docs" / "src" / "content" / "docs" / "specs" - spec.mkdir(parents=True, exist_ok=True) - (spec / "openapm-v0.1.md").write_text("placeholder spec edit\n") + selected = repo / selected_path.relative_to(REPO_ROOT) + selected.write_text(selected.read_text() + "\n# Informative draft note\n") _git(repo, "add", "-A") _git(repo, "commit", "-m", "feature + spec edit") out = _run_detector(repo) @@ -91,6 +98,18 @@ def test_detector_short_circuits_on_spec_concurrent_edit(tmp_path): assert "spec-concurrent edit detected" in out.stdout, out.stdout +def test_retained_previous_minor_does_not_satisfy_active_spec_citation(tmp_path): + """Changing only the previous minor cannot cite the active assessment.""" + repo = _make_repo(tmp_path) + (repo / "src/apm_cli/deps/new.py").write_text("\n".join(f"x = {i}" for i in range(40)) + "\n") + (repo / "docs/src/content/docs/specs/openapm-v0.1.md").write_text("old history\n") + _git(repo, "add", "-A") + _git(repo, "commit", "-m", "critical change with old-only notice") + result = _run_detector(repo) + assert result.returncode == 1 + assert "Mode B detector" in result.stdout + + def test_detector_short_circuits_on_new_top_level_req_marker(tmp_path): """A new column-0 ``@pytest.mark.req`` marker MUST short-circuit (exit 0). @@ -255,6 +274,10 @@ def test_workflow_checkout_uses_full_history(): ) +def test_workflow_triggers_on_public_spec_assets(): + assert "'docs/public/specs/**'" in WORKFLOW.read_text() + + def test_workflow_invokes_detector_after_orphan_check(): """The CI workflow MUST wire the detector as a step.""" body = WORKFLOW.read_text() @@ -317,6 +340,16 @@ def _make_repo(tmp_path: Path) -> Path: shutil.copy2(DETECTOR, repo / "tests" / "spec_conformance" / "mode_b_detector.sh") (repo / "tests" / "spec_conformance" / "mode_b_detector.sh").chmod(0o755) shutil.copy2(PATHS_FILE, repo / "tests" / "spec_conformance" / "critical_paths.txt") + for relative in ("tests/__init__.py", "tests/spec_conformance/__init__.py"): + (repo / relative).write_text("") + shutil.copy2( + REPO_ROOT / "tests/spec_conformance/_manifest.py", + repo / "tests/spec_conformance/_manifest.py", + ) + for source in (SPEC_PATH, MANIFEST_PATH, SCHEMA_PATH): + destination = repo / source.relative_to(REPO_ROOT) + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) _git(repo, "add", "-A") _git(repo, "commit", "-m", "base", "--quiet") # Create a feature branch and an origin/main reference the detector @@ -328,7 +361,12 @@ def _make_repo(tmp_path: Path) -> Path: def _run_detector(repo: Path) -> subprocess.CompletedProcess: - env = {**os.environ, "BASE_REF": "origin/main"} + env = { + **os.environ, + "BASE_REF": "origin/main", + "PYTHON": sys.executable, + "PYTHONPATH": str(repo), + } # Disable the env-var waiver path; we test commit-trailer waivers # by leaving GH_PR_BODY unset. env.pop("GH_PR_BODY", None) @@ -345,7 +383,7 @@ def _run_detector(repo: Path) -> subprocess.CompletedProcess: def _run_detector_with_env(repo: Path, **overrides: str) -> subprocess.CompletedProcess: """Run the detector with explicit env overrides. GH_PR_BODY is cleared so only the supplied vars drive behaviour.""" - env = {**os.environ} + env = {**os.environ, "PYTHON": sys.executable, "PYTHONPATH": str(repo)} env.pop("GH_PR_BODY", None) # Strip any ambient GITHUB_ACTIONS so callers control it explicitly. env.pop("GITHUB_ACTIONS", None) diff --git a/tests/spec_conformance/test_registry_reqs.py b/tests/spec_conformance/test_registry_reqs.py index 6144e1dd6f..d653f56b80 100644 --- a/tests/spec_conformance/test_registry_reqs.py +++ b/tests/spec_conformance/test_registry_reqs.py @@ -2,8 +2,8 @@ req-rg-001 (trust anchor): the SHA-256 of the archive bytes the Registry serves MUST equal the digest the Registry advertises for -that version. This is the ONE substantive normative statement v0.1 -places on Registry implementations; the rest is reserved for v0.2. +that version. This is the ONE substantive normative statement this revision +places on Registry implementations; the broader wire contract remains reserved. The fixture `integrity/security-baseline-2.3.1.tar.gz` simulates a Registry's published archive; its paired diff --git a/tests/spec_conformance/test_resolution_reqs.py b/tests/spec_conformance/test_resolution_reqs.py index 3a1f91126b..638a2f1507 100644 --- a/tests/spec_conformance/test_resolution_reqs.py +++ b/tests/spec_conformance/test_resolution_reqs.py @@ -162,18 +162,18 @@ def test_resolver_records_resolved_ref_in_lockfile(): @pytest.mark.req("req-rs-013") def test_resolver_fails_closed_on_ambiguous_resolution(): - """`conflict_resolution: nest` MUST be rejected in v0.1.""" + """`conflict_resolution: nest` remains refused in the corrective revision.""" assert_spec_contains( "conflict_resolution: nest", - "reserved for v0.2", + "reserved for a future revision", ) # Schema enum pin (round-3 fold): the manifest schema MUST admit - # only `intersection-pick` in v0.1; `nest` is reserved for v0.2. + # only `intersection-pick`; `nest` remains reserved. schema = load_schema("manifest-v0.1.schema.json") enum = schema["$defs"]["depsBlock"]["properties"]["conflict_resolution"]["enum"] assert enum == ["intersection-pick"], ( f"manifest schema conflict_resolution enum MUST be exactly " - f"['intersection-pick'] in v0.1; got {enum!r}" + f"['intersection-pick']; got {enum!r}" ) diff --git a/tests/spec_conformance/test_spec_version_contract.py b/tests/spec_conformance/test_spec_version_contract.py new file mode 100644 index 0000000000..70ee254a3c --- /dev/null +++ b/tests/spec_conformance/test_spec_version_contract.py @@ -0,0 +1,240 @@ +"""Version identity, preservation, and fresh-evidence boundary regressions.""" + +from __future__ import annotations + +import hashlib +import json +import re +import subprocess +from pathlib import Path +from urllib.parse import urlparse + +import pytest +import yaml + +from tests.spec_conformance import _manifest, gen_statement, orphan_check +from tests.spec_conformance._helpers import spec_text + +pytestmark = pytest.mark.component + +PRESERVATION_BASE = "0ae9a78c6129e41caf87cb75130f9006b31ed98d" +PRESERVED_DIGESTS = { + "docs/src/content/docs/specs/openapm-v0.1.md": ( + "88f94a344ffbe5dae6f1cd3e6d3d7661aee6facb8c34298a9d18a808264114c4" + ), + "docs/public/specs/manifests/openapm-v0.1.requirements.yml": ( + "b77dca83e0ffb55122760fb7e2080b7c2db051a29374479e1761da9dcaf4917d" + ), + "docs/public/specs/schemas/lockfile-v0.1.schema.json": ( + "6c0dca9e7994035b55da17340b1f9f6c6673501c63ebd947400cf472d5723560" + ), + "docs/public/specs/schemas/manifest-v0.1.schema.json": ( + "7bdefbe443d3315d71add021c777d776c9cfd4942acb19750a799f46fa0d1344" + ), + "docs/public/specs/schemas/policy-v0.1.schema.json": ( + "577d7ef2aa1f84d280074f1713258afdd3f8bfb96c4b3b7e5b8ac58e0bb724bb" + ), + "docs/public/specs/schemas/requirements-v0.1.schema.json": ( + "5e294eef59498538efbe3b5f9ba54423f5d21dd07dc174d9588e6f15cb8b5d6f" + ), +} + + +@pytest.mark.parametrize("relative,digest", PRESERVED_DIGESTS.items()) +def test_previous_minor_bytes_are_preserved(relative: str, digest: str) -> None: + """Hashes pin the genuine base, not a moving main or the amended old draft.""" + content = (_manifest.REPO_ROOT / relative).read_bytes() + if relative.endswith(".md"): + pattern = ( + rb"\n" + rb".*?\n\n" + ) + content, count = re.subn(pattern, b"", content, flags=re.DOTALL) + assert count == 1 + assert hashlib.sha256(content).hexdigest() == digest, PRESERVATION_BASE + + +def test_selected_identity_citation_and_exact_content_route_agree() -> None: + selection = _manifest.selected_assessment() + assert selection.version == _manifest.load_manifest_raw()["spec_version"] + assert urlparse(selection.citation).path == f"/apm/spec/{selection.version}" + assert selection.version in selection.coverage_path.name + config = (_manifest.REPO_ROOT / "docs/astro.config.mjs").read_text() + redirects = dict(re.findall(r"'(/spec[^']*)': '([^']+)'", config)) + assert urlparse(redirects[f"/spec/{selection.version}"]).path == ( + f"/apm/specs/openapm-{selection.version.replace('.', '')}/" + ) + assert redirects["/spec/latest"] == redirects["/spec/v0.1"] + assert redirects["/spec"] == redirects["/spec/v0.1"] + + +def test_every_exact_citation_resolves_to_its_own_revision() -> None: + """Overwriting the minor-line file for a later patch must not strand an exact pin.""" + config = (_manifest.REPO_ROOT / "docs/astro.config.mjs").read_text() + exact_routes = re.findall(r"'(/spec/v[0-9]+\.[0-9]+\.[0-9]+)': '([^']+)'", config) + artifacts = {} + for path in _manifest.SPEC_DIR.glob("openapm-*.md"): + frontmatter = yaml.safe_load(path.read_text().split("---\n", 2)[1]) + if "slug" in frontmatter: + artifacts[f"/apm/{frontmatter['slug']}/"] = frontmatter["title"] + assert exact_routes + for source, destination in exact_routes: + version = urlparse(source).path.rsplit("/", 1)[-1] + assert artifacts[urlparse(destination).path] == f"OpenAPM {version}" + + +@pytest.mark.parametrize( + "old,new", + [ + ("title: OpenAPM v0.2.0", "title: OpenAPM v0.1"), + ("slug: specs/openapm-v020", "slug: specs/openapm-v02"), + ], +) +def test_artifact_identity_mismatch_is_rejected( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, old: str, new: str +) -> None: + artifact = tmp_path / "selected.md" + artifact.write_text(_manifest.SPEC_PATH.read_text().replace(old, new), encoding="utf-8") + monkeypatch.setattr(_manifest, "SPEC_PATH", artifact) + with pytest.raises(ValueError, match=r"identity|exact-revision"): + _manifest.selected_assessment() + + +@pytest.mark.parametrize("version", ["v0.1.39", "v0.2"]) +def test_manifest_must_select_an_exact_revision_of_the_active_minor( + monkeypatch: pytest.MonkeyPatch, version: str +) -> None: + raw = {**_manifest.load_manifest_raw(), "spec_version": version} + monkeypatch.setattr(_manifest, "load_manifest_raw", lambda: raw) + with pytest.raises(ValueError, match=r"exact specification|minor artifact"): + _manifest.selected_assessment() + + +@pytest.mark.parametrize("field", ["spec_version", "spec_sha256", "manifest_sha256"]) +def test_mismatched_or_stale_coverage_is_rejected(tmp_path: Path, field: str) -> None: + document = _manifest.coverage_document({}) + document[field] = "stale" + path = tmp_path / "coverage.json" + path.write_text(json.dumps(document)) + with pytest.raises(ValueError, match="fingerprints"): + _manifest.load_coverage(path) + + +def test_unversioned_old_coverage_is_rejected(tmp_path: Path) -> None: + path = tmp_path / "old-coverage.json" + path.write_text(json.dumps({"req-mf-016": [{"test_nodeid": "old", "status": "active"}]})) + with pytest.raises(ValueError, match="identity"): + _manifest.load_coverage(path) + + +@pytest.mark.parametrize("exit_code,write_output", [(1, True), (2, False), (0, False)]) +def test_failed_or_incomplete_collection_cannot_reuse_coverage( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + exit_code: int, + write_output: bool, +) -> None: + prior = tmp_path / "old.json" + prior.write_text(json.dumps(_manifest.coverage_document({}))) + monkeypatch.setenv(_manifest.COVERAGE_ENV, str(prior)) + + def collect(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + env = kwargs["env"] + assert isinstance(env, dict) + output = Path(env[_manifest.COVERAGE_ENV]) + assert output != prior + if write_output: + output.write_text(json.dumps(_manifest.coverage_document({}))) + return subprocess.CompletedProcess(command, exit_code, "collection details", "failed") + + monkeypatch.setattr(_manifest.subprocess, "run", collect) + with pytest.raises(RuntimeError, match=r"collection failed|no binding inventory"): + _manifest.collect_coverage() + assert prior.is_file() + + +def test_every_collection_is_fresh_and_ignores_ambient_test_selectors( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("PYTEST_ADDOPTS", "-k no_tests --lf") + outputs: list[Path] = [] + + def collect(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + env = kwargs["env"] + assert isinstance(env, dict) + assert "PYTEST_ADDOPTS" not in env + assert command[3:5] == ["tests/spec_conformance", "--collect-only"] + assert "addopts=" in command + output = Path(env[_manifest.COVERAGE_ENV]) + assert not output.exists() + outputs.append(output) + output.write_text(json.dumps(_manifest.coverage_document({}))) + return subprocess.CompletedProcess(command, 0, "", "") + + monkeypatch.setattr(_manifest.subprocess, "run", collect) + assert _manifest.collect_coverage() == {} + assert _manifest.collect_coverage() == {} + assert len(set(outputs)) == 2 + assert all(not path.exists() for path in outputs) + + +def test_generator_refuses_collection_failure_without_overwriting_outputs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + json_path, md_path = tmp_path / "report.json", tmp_path / "report.md" + json_path.write_bytes(b"original-json") + md_path.write_bytes(b"original-md") + monkeypatch.setattr(gen_statement, "CONFORMANCE_JSON", json_path) + monkeypatch.setattr(gen_statement, "CONFORMANCE_MD", md_path) + + def fail() -> _manifest.Coverage: + raise RuntimeError("collection failed") + + monkeypatch.setattr(gen_statement, "collect_coverage", fail) + assert gen_statement.main() == 2 + assert json_path.read_bytes() == b"original-json" + assert md_path.read_bytes() == b"original-md" + + +def test_orphan_check_refuses_failed_collection(monkeypatch: pytest.MonkeyPatch) -> None: + def fail() -> _manifest.Coverage: + raise RuntimeError("collection failed despite a stale map") + + monkeypatch.setattr(orphan_check, "collect_coverage", fail) + assert orphan_check.main() == 2 + + +def test_generator_requires_complete_current_bindings(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(gen_statement, "collect_coverage", lambda: {}) + with pytest.raises(ValueError, match="four-way bind"): + gen_statement.build_json() + + +def test_reserved_features_are_not_activated_by_the_new_minor() -> None: + text = spec_text().split("## Appendix D.", 1)[0] + for phrase in ( + "**producer** MUST NOT\ndeclare a top-level `workspaces:` key", + "MUST refuse the install", + "Readers MUST accept bare 64-character", + "one of `sha256`, `sha384`, or `sha512`", + "The frozen-install operation is opt-in in\nthis revision", + "broader wire contract remains reserved", + "manifest remains informative in this revision", + "cryptographic publisher provenance and does not activate", + ): + assert phrase in text + assert not re.search(r"v0\.2 (?:will|introduces|normative)", text) + assert "reserved-for-v02" not in text + + +def test_report_identity_and_links_follow_the_selected_assessment() -> None: + document = gen_statement.build_json() + selection = _manifest.selected_assessment() + assert document["spec_version"] == selection.version + assert document["inventory_kind"] == "static-test-bindings" + assert urlparse(document["spec_citation"]) == urlparse(selection.citation) + markdown = gen_statement.build_md(document) + assert "not executed test results or a runtime pass certificate" in markdown + requirement_links = re.findall(r"\[req-[^\]]+\]\(([^)]+)\)", markdown) + assert len(requirement_links) == len(document["requirements"]) + assert {urlparse(link).path for link in requirement_links} == {document["spec_path"]} diff --git a/tests/unit/scripts/test_architecture_runner.py b/tests/unit/scripts/test_architecture_runner.py index a3c92b5cb3..4205036abb 100644 --- a/tests/unit/scripts/test_architecture_runner.py +++ b/tests/unit/scripts/test_architecture_runner.py @@ -612,6 +612,7 @@ def exiting_import( contracts-tooling-lockfile-timestamp-constructor contracts-tooling-lockfile-timestamp-fallback contracts-tooling-project-yaml-write-delegation +contracts-tooling-spec-assessment install-deployment-approval-outcome-routing install-deployment-audit-policy-discovery install-deployment-audit-replay From 3069210947c94aae6c960eb5f3cec16a83c0f067 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sun, 6 Sep 2026 13:31:41 +0200 Subject: [PATCH 07/34] test(spec): retain provenance mutation coverage without duplicate guard Keep the canonical matrix's established-source-kind mutation and preserve the earlier declaring-parent delegation mutation as a separate behavioral guard. This resolves the exact-once inventory collision from integrating the immutable provenance fix without dropping either assertion. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../test_architecture_owner_rule_mutations.py | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/tests/integration/test_architecture_owner_rule_mutations.py b/tests/integration/test_architecture_owner_rule_mutations.py index 46e2abf07c..a6db6060dd 100644 --- a/tests/integration/test_architecture_owner_rule_mutations.py +++ b/tests/integration/test_architecture_owner_rule_mutations.py @@ -325,14 +325,6 @@ class MutationCase: new="def set(", intent="Config mutation stops routing through the canonical lifecycle lock.", ), - MutationCase( - guard_id="install-deployment-local-scope-admission", - rule_id="install-deployment-local-scope-admission", - path="src/apm_cli/install/phases/resolve.py", - old="user_scope_rejection_reason(dep_ref, scope, parent_pkg=parent_pkg)", - new="user_scope_rejection_reason(dep_ref, scope, parent_pkg=None)", - intent="Resolution drops the declaring local parent's source context from admission.", - ), MutationCase( guard_id="install-deployment-local-scope-admission", rule_id="install-deployment-local-scope-admission", @@ -1121,6 +1113,21 @@ def test_owner_rules_report_nothing_before_mutation( assert baseline_violated_rule_ids == frozenset() +def test_local_scope_guard_retains_declaring_parent_delegation() -> None: + """Retain the earlier admission mutation alongside the stronger provenance case.""" + case = MutationCase( + guard_id="install-deployment-local-scope-admission", + rule_id="install-deployment-local-scope-admission", + path="src/apm_cli/install/phases/resolve.py", + old="user_scope_rejection_reason(dep_ref, scope, parent_pkg=parent_pkg)", + new="user_scope_rejection_reason(dep_ref, scope, parent_pkg=None)", + intent="Resolution drops the declaring local parent's source context from admission.", + ) + report = run_selected_rules(ROOT, (case.rule_id,), source_overrides={case.path: _mutate(case)}) + assert not report.failures + assert {finding.rule_id for finding in report.violations} == {case.rule_id} + + 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" From 70b772e8fa3a74c2595cd1204fee48ef1aa700d0 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sat, 5 Sep 2026 10:45:11 +0200 Subject: [PATCH 08/34] fix(audit): replay canonical current target intent Use manifest targets, saved configuration, then existing audit detection without inferring historical install overrides from ownership. Reuse live target profiles in warm and cold replay, fail unavailable intent explicitly, and keep old file and directory claims in comparison after target contraction. Add hermetic source-CLI and component regressions, authority mutations, and audit/config documentation. No lockfile schema or experimental manifest changes. Fixes #2816 Refs #2813 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .apm/architecture/owners/core-runtime.json | 7 +- docs/src/content/docs/reference/cli/audit.md | 14 + docs/src/content/docs/reference/cli/config.md | 2 +- .../.apm/skills/apm-usage/commands.md | 7 + .../checks/registry_owner_guards.py | 72 ++++- src/apm_cli/install/audit_replay.py | 18 +- src/apm_cli/install/audit_target_roots.py | 47 +++- src/apm_cli/install/drift.py | 105 ++++--- src/apm_cli/policy/ci_checks.py | 63 +++-- ...architecture_confirmed_bypass_mutations.py | 37 +++ .../test_audit_target_intent_e2e.py | 259 ++++++++++++++++++ tests/unit/install/test_audit_target_roots.py | 126 +++++++++ tests/unit/install/test_drift_detection.py | 22 +- tests/unit/install/test_drift_phase3.py | 22 +- 14 files changed, 682 insertions(+), 119 deletions(-) create mode 100644 tests/integration/test_audit_target_intent_e2e.py create mode 100644 tests/unit/install/test_audit_target_roots.py diff --git a/.apm/architecture/owners/core-runtime.json b/.apm/architecture/owners/core-runtime.json index e07a27a0f4..2075d1b5bf 100644 --- a/.apm/architecture/owners/core-runtime.json +++ b/.apm/architecture/owners/core-runtime.json @@ -10,9 +10,12 @@ }, { "id": "effective-install-target-selection", - "decision": "Effective install target selection", + "decision": "Effective install and audit target selection", "owner": "core/target_detection.py (EffectiveTargetDecision)", - "selectors": ["src/apm_cli/core/target_detection.py"], + "selectors": [ + "src/apm_cli/core/target_detection.py", + "src/apm_cli/install/audit_target_roots.py" + ], "guards": ["registry-delegation-install-target-selection"] }, { diff --git a/docs/src/content/docs/reference/cli/audit.md b/docs/src/content/docs/reference/cli/audit.md index ca34ddcbfa..eb143554e8 100644 --- a/docs/src/content/docs/reference/cli/audit.md +++ b/docs/src/content/docs/reference/cli/audit.md @@ -179,6 +179,20 @@ For the full workflow, see [Enforce in CI](../../../enterprise/enforce-in-ci/). ### Drift detection +Audit evaluates **current target intent**: `apm.yml target(s)` first, then +[`apm config set target`](../config/), then existing directory detection. +It does not reconstruct an earlier one-shot `apm install --target` override. +Keep the intended target in the manifest or saved configuration; experimental +targets such as `grok-cloud` use the saved configuration because manifest +target declarations do not accept experimental names. + +Changing that intent changes the expected output. Old recorded deployments +remain in the comparison, and missing ownership does not remove source-derived +expectations. Experimental targets still require enablement and their runtime +prerequisites; an unavailable selected target fails CI as `target-resolution`, +rather than passing an empty replay. Audit reads configuration without creating +or updating it. + The default audit replays the install pipeline into a scratch tree and diffs the result against the working tree. It catches hand-edits, missing integrations, orphaned files, and `unrecorded` files. `unrecorded` applies when diff --git a/docs/src/content/docs/reference/cli/config.md b/docs/src/content/docs/reference/cli/config.md index a7b8aae605..d140988a92 100644 --- a/docs/src/content/docs/reference/cli/config.md +++ b/docs/src/content/docs/reference/cli/config.md @@ -65,7 +65,7 @@ Remove `KEY` from `~/.apm/config.json`. No-op if the key is not set. Supported u | Key | Type | Default | Description | | --- | --- | --- | --- | | `auto-integrate` | boolean | `true` | Auto-discover `.prompt.md` files under `.github/prompts/` and `.apm/prompts/` and merge them into compiled `AGENTS.md` output. | -| `target` | target token | unset | Default target for package, MCP, and LSP phases of `apm install` and `apm update` when `--target` and `apm.yml target(s)` are absent. Uses the same parser as `apm install --target` (single or comma-separated). | +| `target` | target token | unset | Default target for package, MCP, and LSP phases of `apm install` and `apm update` when `--target` and `apm.yml target(s)` are absent. Also supplies [audit's current target intent](../audit/#drift-detection) when the manifest declares no targets. Uses the `apm install --target` parser (single or comma-separated). | | `self-update.channel` | enum | `stable` | Default release channel for `apm self-update`: `stable` selects the latest stable release; `prerelease` selects the newest non-draft prerelease. Both pass the selected release to the installer as one normalized `VERSION`. `APM_SELF_UPDATE_CHANNEL` overrides config. | | `self-update.install-dir` | path | installer default | Default target directory passed to the self-update installer as `APM_INSTALL_DIR`. `APM_INSTALL_DIR` overrides config. | | `temp-dir` | path | system temp | Directory used for clone and download operations. Useful when the OS temp directory is locked down (for example, corporate Windows endpoints rejecting `%TEMP%` with `[WinError 5]`). | diff --git a/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md index 96dc507dd9..bf8cffe0b5 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/commands.md +++ b/packages/apm-guide/.apm/skills/apm-usage/commands.md @@ -223,6 +223,13 @@ descendants, are skipped. ## Security and audit +Audit replays current target intent: manifest `target(s)` > saved `apm config +set target` > existing directory detection. An unsaved earlier `install +--target` override is not recovered from ownership records. Experimental targets +require their prerequisites and use saved configuration, not experimental +manifest declarations. Changing targets does not exempt old claimed files or +missing ownership from drift detection. Configuration is read-only during audit. + | Command | Purpose | Key flags | |---------|---------|-----------| | `apm audit [PKG]` | Scan installed primitives for hidden Unicode, drift, and lockfile/policy violations | `--file PATH`, `--strip`, `--dry-run`, `-v`, `-f [text\|json\|sarif\|md]`, `-o PATH`, `--ci`, `--policy SOURCE`, `--no-cache`, `--no-fail-fast`, `--no-drift`, `--external NAME` (experimental; ingest a third-party SARIF scanner, e.g. `skillspector`), `--external-sarif PATH`, `--external-llm/--no-external-llm`, `--external-args TEXT` | diff --git a/scripts/architecture_linter/checks/registry_owner_guards.py b/scripts/architecture_linter/checks/registry_owner_guards.py index c6e92af045..ff89786c18 100644 --- a/scripts/architecture_linter/checks/registry_owner_guards.py +++ b/scripts/architecture_linter/checks/registry_owner_guards.py @@ -250,7 +250,7 @@ def _check_install_target_selection(provider: FactsProvider) -> Iterable[Violati if failures: return failures - findings: list[Violation] = [] + findings = list(_check_audit_target_selection(provider, rule_id)) owner = facts_by_path[_EFFECTIVE_TARGET_OWNER] install = facts_by_path[_INSTALL_CMD] pipeline = facts_by_path[_INSTALL_PIPELINE] @@ -332,6 +332,76 @@ def _check_install_target_selection(provider: FactsProvider) -> Iterable[Violati return findings +def _check_audit_target_selection(provider: FactsProvider, rule_id: str) -> Iterable[Violation]: + """Audit adapts the canonical decision and never redetects scratch targets.""" + adapter = "src/apm_cli/install/audit_target_roots.py" + required = { + adapter: { + "resolve_effective_target_decision", + "read_declared_target_names", + "resolve_targets", + }, + "src/apm_cli/install/drift.py": {"resolve_audit_targets"}, + "src/apm_cli/install/audit_replay.py": {"resolve_audit_targets"}, + "src/apm_cli/policy/ci_checks.py": {"resolve_audit_targets"}, + } + for path, expected_calls in required.items(): + _facts, failures = checked_facts(provider, path, rule_id, require_python=True) + if failures: + yield from failures + continue + index = provider.tree_index(path) + if index is None: + yield violation(rule_id, path, "audit target delegation has no parsed source") + continue + calls = [ + node + for node in index.walk(index.root) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) + ] + missing = expected_calls - {node.func.id for node in calls} + if missing: + yield violation( + rule_id, + path, + f"audit target delegation is missing calls: {', '.join(sorted(missing))}", + ) + for call in calls: + if path != adapter and call.func.id in {"resolve_targets", "_read_apm_yml_target"}: + yield violation( + rule_id, + path, + "audit consumers must use resolve_audit_targets", + line=call.lineno, + ) + if path == adapter and call.func.id in { + "resolve_effective_target_decision", + "resolve_targets", + }: + keywords = {keyword.arg: keyword.value for keyword in call.keywords} + value = keywords.get("create_config") + if not isinstance(value, ast.Constant) or value.value is not False: + yield violation( + rule_id, + path, + "audit target resolution must not create config", + line=call.lineno, + ) + if path != adapter: + for node in index.walk(index.root): + if ( + isinstance(node, ast.ImportFrom) + and (node.module or "").endswith("integration.targets") + and any(alias.name == "resolve_targets" for alias in node.names) + ): + yield violation( + rule_id, + path, + "audit target consumers must not import a parallel resolver", + line=node.lineno, + ) + + def _check_output_diagnostics(provider: FactsProvider) -> Iterable[Violation]: """Doctor status symbols must come from ``utils/console.py::STATUS_SYMBOLS``. diff --git a/src/apm_cli/install/audit_replay.py b/src/apm_cli/install/audit_replay.py index d5dc5df647..ea8efe02e3 100644 --- a/src/apm_cli/install/audit_replay.py +++ b/src/apm_cli/install/audit_replay.py @@ -15,15 +15,15 @@ from pathlib import Path from apm_cli.deps.lockfile import LockFile, get_lockfile_path +from apm_cli.install.audit_target_roots import AuditTargetError, resolve_audit_targets from apm_cli.install.drift import ( CheckLogger, ReplayConfig, _make_scratch_root, - _read_apm_yml_target, run_replay, ) from apm_cli.install.plan import lockfile_satisfies_manifest -from apm_cli.integration.targets import TargetProfile, resolve_targets +from apm_cli.integration.targets import TargetProfile from apm_cli.models.apm_package import APMPackage @@ -87,6 +87,10 @@ def prepare_ci_audit_replay( if not satisfied: raise CiAuditReplayError("apm.lock.yaml is out of sync with apm.yml. " + " ".join(reasons)) + try: + targets = resolve_audit_targets(project_root, user_scope=user_scope) + except AuditTargetError as exc: + raise CiAuditReplayError(str(exc)) from exc scratch_root = _make_scratch_root(project_root) modules_root = scratch_root / "apm_modules" config = ReplayConfig( @@ -96,6 +100,7 @@ def prepare_ci_audit_replay( scratch_root=scratch_root, modules_root=modules_root, user_scope=user_scope, + resolved_targets=targets, ) stderr_context = ( contextlib.nullcontext() if verbose else contextlib.redirect_stderr(io.StringIO()) @@ -106,17 +111,10 @@ def prepare_ci_audit_replay( except Exception as exc: raise CiAuditReplayError(str(exc)) from exc - explicit_target = _read_apm_yml_target(project_root) return PreparedCiAuditReplay( scratch_root=scratch_root, modules_root=modules_root, lockfile_path=lockfile_path, tracked_files=_git_tracked_files(project_root), - targets=tuple( - resolve_targets( - scratch_root, - user_scope=user_scope, - explicit_target=explicit_target, - ) - ), + targets=targets, ) diff --git a/src/apm_cli/install/audit_target_roots.py b/src/apm_cli/install/audit_target_roots.py index 6e0380895c..65f277556c 100644 --- a/src/apm_cli/install/audit_target_roots.py +++ b/src/apm_cli/install/audit_target_roots.py @@ -1,16 +1,59 @@ -"""Scratch projection helpers for audit target deployment roots.""" +"""Read-only target resolution and scratch deployment roots for audit.""" from __future__ import annotations from dataclasses import replace from pathlib import Path -from apm_cli.integration.targets import TargetProfile +import click +import yaml + +from apm_cli.integration.targets import TargetProfile, resolve_targets from apm_cli.utils.path_security import PathTraversalError, ensure_path_within _EXTERNAL_REPLAY_ROOT = ".apm-audit-targets" +class AuditTargetError(ValueError): + """Current audit target intent cannot be resolved safely.""" + + +def resolve_audit_targets( + project_root: Path, *, user_scope: bool = False +) -> tuple[TargetProfile, ...]: + """Adapt the canonical current-intent decision to read-only audit profiles.""" + from apm_cli.core.apm_yml import read_declared_target_names + from apm_cli.core.target_detection import resolve_effective_target_decision + + try: + decision = resolve_effective_target_decision( + project_root, + explicit_target=None, + manifest_target=read_declared_target_names(project_root), + user_scope=user_scope, + auto_detect=False, + create_config=False, + ) + selected_targets = decision.canonical_targets + targets = resolve_targets( + project_root, + user_scope=user_scope, + explicit_target=list(selected_targets) if selected_targets is not None else None, + create_config=False, + ) + except (OSError, ValueError, yaml.YAMLError, click.ClickException) as exc: + raise AuditTargetError(str(exc)) from exc + + unavailable = set(selected_targets or ()) - {target.name for target in targets} + if unavailable: + names = ", ".join(sorted(unavailable)) + raise AuditTargetError( + f"Cannot audit selected target(s): {names}. Restore their experimental/runtime " + "prerequisites for this scope, or correct apm.yml / 'apm config set target'." + ) + return tuple(targets) + + def replay_target(target: TargetProfile) -> TargetProfile: """Return a scratch-contained profile for replay-only integration.""" if target.managed_deploy_root is None: diff --git a/src/apm_cli/install/drift.py b/src/apm_cli/install/drift.py index 2ce8b03365..d94e3de19d 100644 --- a/src/apm_cli/install/drift.py +++ b/src/apm_cli/install/drift.py @@ -88,6 +88,7 @@ class ReplayConfig: scratch_root: Path | None = None modules_root: Path | None = None user_scope: bool = False + resolved_targets: tuple[TargetProfile, ...] | None = None @dataclass(frozen=True) @@ -506,40 +507,11 @@ def _filter_targets(all_targets, names: frozenset[str] | None): return [t for t in all_targets if t.name in names] -def _read_apm_yml_target(project_root: Path): - """Return the explicit target list from ``apm.yml`` if declared, else ``None``. +def _read_apm_yml_target(project_root: Path) -> list[str] | None: + """Compatibility wrapper for the authoritative manifest target reader.""" + from apm_cli.core.apm_yml import read_declared_target_names - Handles both the singular ``target:`` and plural ``targets:`` forms so - that the replay uses the same target set the install pipeline used. - Without this, a project with ``targets: [claude, codex]`` (no copilot) - that also has a ``.github/`` directory for unrelated CI workflows would - have copilot auto-detected during replay, producing false - ``unintegrated`` findings for ``.github/instructions/`` (#1924). - """ - apm_yml = project_root / "apm.yml" - if not apm_yml.exists(): - return None - try: - # Route through the merge/alias-bounded loader (not stock yaml.safe_load) - # so a hostile apm.yml shipped in a cloned repo cannot wedge the default-on - # ``apm audit`` drift replay with a billion-laughs merge/alias bomb. - from apm_cli.utils.yaml_io import load_yaml - - data = load_yaml(apm_yml) or {} - except Exception: - # Manifest unreadable / corrupt: fall back to auto-detect rather - # than crashing the replay; the caller still surfaces a useful - # error elsewhere if the project is truly broken. - return None - # parse_targets_field handles both 'target:' (singular) and 'targets:' - # (plural list) and validates the tokens against the canonical set. - try: - from apm_cli.core.apm_yml import parse_targets_field - - tokens = parse_targets_field(data) - return tokens if tokens else None - except Exception: - return None + return read_declared_target_names(project_root) def run_replay(config: ReplayConfig, logger: CheckLogger) -> Path: @@ -554,9 +526,8 @@ def run_replay(config: ReplayConfig, logger: CheckLogger) -> Path: Surfaced verbatim when a locked dep cannot be materialized. """ from apm_cli.deps.lockfile import _SELF_KEY, LockFile - from apm_cli.install.audit_target_roots import replay_target + from apm_cli.install.audit_target_roots import replay_target, resolve_audit_targets from apm_cli.install.services import IntegratorBundle, integrate_package_primitives - from apm_cli.integration.targets import resolve_targets from apm_cli.utils.diagnostics import DiagnosticCollector if not config.lockfile_path.exists(): @@ -583,16 +554,10 @@ def run_replay(config: ReplayConfig, logger: CheckLogger) -> Path: ) live_modules_dir = project_root / "apm_modules" - # Honor apm.yml's ``target:`` field so multi-target projects replay - # into all governed roots (not just whichever directory happens to - # already exist via auto-detection). Without this, a project that - # targets ``copilot,claude,cursor`` would replay only the primary - # auto-detected target and report the others as ``orphaned``. - explicit_target = _read_apm_yml_target(project_root) - live_targets = resolve_targets( - project_root, - user_scope=config.user_scope, - explicit_target=explicit_target, + live_targets = ( + config.resolved_targets + if config.resolved_targets is not None + else resolve_audit_targets(project_root, user_scope=config.user_scope) ) targets = [replay_target(target) for target in _filter_targets(live_targets, config.targets)] registries: dict[str, str] | None = None @@ -887,18 +852,36 @@ def diff_scratch_against_project( absolute_only=absolute_claims_only, targets=tuple(targets), ) - claimed_prefixes = _claimed_prefixes( - tracked, - set( - claims_for_root( - {path: "" for path in _collect_hashed_files(lockfile)}, - project_root, - absolute_only=absolute_claims_only, - targets=tuple(targets), - ) - ), - project_files, + hashed_files = set( + claims_for_root( + {path: "" for path in _collect_hashed_files(lockfile)}, + project_root, + absolute_only=absolute_claims_only, + targets=tuple(targets), + ) ) + # A narrowed desired target set must not hide files still claimed under + # the old target. Claims widen comparison only, never source replay. + from apm_cli.utils.path_security import ensure_path_within, has_symlink_component + + for rel in tracked: + if rel not in project_files: + candidate = project_root / rel + if has_symlink_component(project_root, candidate): + continue + path = ensure_path_within(candidate, project_root) + if path.is_file(): + project_files[rel] = path + elif path.is_dir() and rel not in hashed_files: + project_files.update(_walk_managed(project_root, {rel})) + claimed_prefixes = _claimed_prefixes(tracked, hashed_files, project_files) + prefix_set = set(claimed_prefixes) + prefix_owners = { + path.rstrip("/") + "/": owner + for path, owner in tracked.items() + if path.rstrip("/") + "/" in prefix_set + } + owner_prefixes = sorted(prefix_owners, key=len, reverse=True) # Hook merge targets are shared with the user and never claimed in # deployed_files, so they can never be "unrecorded". Their APM-owned slice # is compared through hook_ownership; sidecars remain byte-for-byte owned. @@ -1006,12 +989,18 @@ def _is_canvas(rel: str) -> bool: for rel in sorted(project_files.keys()): if rel in scratch_files: continue - if rel in tracked: + owner = tracked.get(rel) + if owner is None: + owner = next( + (prefix_owners[prefix] for prefix in owner_prefixes if rel.startswith(prefix)), + None, + ) + if owner is not None: findings.append( DriftFinding( path=rel, kind="orphaned", - package=tracked.get(rel, ""), + package=owner, ) ) # else: untracked governed file -- ignore (user authored). diff --git a/src/apm_cli/policy/ci_checks.py b/src/apm_cli/policy/ci_checks.py index 5fa57f89f1..18f1a1ddbc 100644 --- a/src/apm_cli/policy/ci_checks.py +++ b/src/apm_cli/policy/ci_checks.py @@ -724,31 +724,41 @@ def _check_drift( from ..core.scope import get_workspace_deploy_root from ..deps.lockfile import get_lockfile_path from ..deps.path_anchoring import LocalResolutionError - from ..install.audit_target_roots import external_replay_root + from ..install.audit_target_roots import ( + AuditTargetError, + external_replay_root, + resolve_audit_targets, + ) from ..install.drift import ( CacheMissError, CheckLogger, ReplayConfig, - _read_apm_yml_target, diff_scratch_against_project, run_replay, ) - from ..integration.targets import resolve_targets logger = CheckLogger(verbose=verbose) deployment_root = get_workspace_deploy_root(project_root) user_scope = deployment_root != project_root.resolve() if prepared_replay is None: - config = ReplayConfig( - project_root=project_root, - lockfile_path=get_lockfile_path(project_root), - targets=frozenset(targets) if targets else None, - cache_only=cache_only, - user_scope=user_scope, - ) - try: + resolved_targets = list(resolve_audit_targets(project_root, user_scope=user_scope)) + config = ReplayConfig( + project_root=project_root, + lockfile_path=get_lockfile_path(project_root), + targets=frozenset(targets) if targets else None, + cache_only=cache_only, + user_scope=user_scope, + resolved_targets=tuple(resolved_targets), + ) scratch = run_replay(config, logger) + except AuditTargetError as exc: + return ( + CheckResult( + name="drift", passed=False, message=f"drift target resolution failed: {exc}" + ), + [], + ) except AgentPluginDeploymentBoundaryError as exc: return ( CheckResult( @@ -792,11 +802,6 @@ def _check_drift( ), [], ) - resolved_targets = resolve_targets( - project_root, - user_scope=user_scope, - explicit_target=_read_apm_yml_target(project_root), - ) tracked_files = None else: scratch = prepared_replay.scratch_root @@ -887,14 +892,6 @@ def run_baseline_checks( result = CIAuditResult() deployment_root = get_workspace_deploy_root(project_root) user_scope = deployment_root != project_root.resolve() - from ..install.drift import _read_apm_yml_target - from ..integration.targets import resolve_targets - - resolved_targets = resolve_targets( - deployment_root, - user_scope=user_scope, - explicit_target=_read_apm_yml_target(project_root), - ) apm_yml_path = project_root / "apm.yml" # Parse manifest ONCE -- this function owns parse-error handling. @@ -964,8 +961,24 @@ def _run(check: CheckResult) -> bool: if _run(_check_deployment_ledger_owners(lock)): return result + from ..install.audit_target_roots import AuditTargetError, resolve_audit_targets + + target_error = None + try: + resolved_targets = ( + prepared_replay.targets + if prepared_replay is not None + else resolve_audit_targets(project_root, user_scope=user_scope) + ) + except AuditTargetError as exc: + target_error = CheckResult(name="target-resolution", passed=False, message=str(exc)) + result.checks.append(target_error) + resolved_targets = () + # Check 4: Deployed files present - if _run(_check_deployed_files_present(deployment_root, lock, resolved_targets)): + if target_error is not None or _run( + _check_deployed_files_present(deployment_root, lock, resolved_targets) + ): return result # Check 5: No orphaned packages diff --git a/tests/integration/test_architecture_confirmed_bypass_mutations.py b/tests/integration/test_architecture_confirmed_bypass_mutations.py index 928927ac17..f94effff4a 100644 --- a/tests/integration/test_architecture_confirmed_bypass_mutations.py +++ b/tests/integration/test_architecture_confirmed_bypass_mutations.py @@ -31,6 +31,43 @@ class BypassMutation: BYPASS_MUTATIONS: tuple[BypassMutation, ...] = ( + BypassMutation( + name="audit-canonical-target-owner", + rule_id="registry_delegation.install_target_selection", + path="src/apm_cli/install/audit_target_roots.py", + old="decision = resolve_effective_target_decision(", + new="decision = private_target_decision(", + ), + BypassMutation( + name="audit-read-only-config", + rule_id="registry_delegation.install_target_selection", + path="src/apm_cli/install/audit_target_roots.py", + old="create_config=False", + new="create_config=True", + replace_all=True, + ), + BypassMutation( + name="audit-warm-replay-private-targets", + rule_id="registry_delegation.install_target_selection", + path="src/apm_cli/install/drift.py", + old="else resolve_audit_targets(project_root, user_scope=config.user_scope)", + new="else private_target_profiles(project_root, user_scope=config.user_scope)", + ), + BypassMutation( + name="audit-cold-replay-scratch-detection", + rule_id="registry_delegation.install_target_selection", + path="src/apm_cli/install/audit_replay.py", + old=" targets=targets,\n )", + new=" targets=resolve_targets(scratch_root),\n )", + ), + BypassMutation( + name="audit-baseline-private-targets", + rule_id="registry_delegation.install_target_selection", + path="src/apm_cli/policy/ci_checks.py", + old="resolve_audit_targets(project_root, user_scope=user_scope)", + new="private_target_profiles(project_root, user_scope=user_scope)", + replace_all=True, + ), BypassMutation( name="target-context-exemption", rule_id="registry_delegation.install_target_selection", diff --git a/tests/integration/test_audit_target_intent_e2e.py b/tests/integration/test_audit_target_intent_e2e.py new file mode 100644 index 0000000000..4b8fe89e2f --- /dev/null +++ b/tests/integration/test_audit_target_intent_e2e.py @@ -0,0 +1,259 @@ +"""Installed-CLI proofs that audit replays current configured target intent.""" + +from __future__ import annotations + +import hashlib +import json +import os +import shutil +from dataclasses import dataclass, replace +from pathlib import Path + +import pytest + +from apm_cli.deps.lockfile import LockFile +from apm_cli.utils.yaml_io import dump_yaml, load_yaml +from tests.utils.apm_lifecycle_runner import ApmLifecycleRunner, CommandResult +from tests.utils.artifact_snapshot import ArtifactSnapshot, assert_unchanged +from tests.utils.isolated_apm_environment import IsolatedApmEnvironment +from tests.utils.local_package import LocalPackageFactory + +pytestmark = [ + pytest.mark.integration, + pytest.mark.e2e, + pytest.mark.lifecycle_smoke, + pytest.mark.lifecycle_merge_group, + pytest.mark.requires_apm_binary, + pytest.mark.requires_e2e_mode, +] + +_SKILL_NAME = "audit-target-intent" +_SKILL_PATH = f".grok/skills/{_SKILL_NAME}/SKILL.md" +_AUDIT_ARGS = ("audit", "--ci", "--no-policy", "--no-fail-fast", "--format", "json") + + +@dataclass(frozen=True) +class _Scenario: + isolated: IsolatedApmEnvironment + project: Path + skill: Path + runner: ApmLifecycleRunner + + def run(self, *args: str, expected_exit: int = 0) -> CommandResult: + """Run the real CLI with the scenario's isolated environment.""" + result = self.runner.run( + args, + scenario_id="audit-target-intent", + cwd=self.project, + env=self.isolated.subprocess_env(), + ) + assert result.returncode == expected_exit, ( + f"{result.command}\n{result.stdout}\n{result.stderr}" + ) + return result + + def audit(self, *, expected_exit: int = 0) -> dict: + """Audit without changing the project or the user's configuration.""" + before_project = ArtifactSnapshot.capture(self.project) + before_config = ArtifactSnapshot.capture(self.isolated.config_root) + result = self.run(*_AUDIT_ARGS, expected_exit=expected_exit) + assert_unchanged(before_project, ArtifactSnapshot.capture(self.project)) + assert_unchanged(before_config, ArtifactSnapshot.capture(self.isolated.config_root)) + return json.loads(result.stdout) + + +def _install_grok( + tmp_path: Path, + apm_binary_path: Path, +) -> _Scenario: + """Install a local skill without any manifest target declaration.""" + isolated = IsolatedApmEnvironment.create(tmp_path / "scenario", base_env=dict(os.environ)) + sources = LocalPackageFactory(isolated.package_root) + package = sources.create("audit-source") + skill = sources.add_skill( + package, + _SKILL_NAME, + f"---\nname: {_SKILL_NAME}\ndescription: Audit target fixture\n---\n" + "# Source-derived clean skill\n", + ) + project = LocalPackageFactory(isolated.work_root).create( + "audit-consumer", + dependencies=({"path": str(package.root)},), + ) + sentinel = project.root / ".github/workflows/unrelated.yml" + sentinel.parent.mkdir(parents=True) + sentinel.write_text("name: unrelated\n", encoding="utf-8") + scenario = _Scenario( + isolated, + project.root, + skill, + ApmLifecycleRunner((str(apm_binary_path),), scenario_timeout_seconds=180), + ) + scenario.run("experimental", "enable", "grok-cloud") + scenario.run("config", "set", "target", "grok-cloud") + scenario.run("install", "--target", "grok-cloud", "--no-policy", "--parallel-downloads", "0") + + deployed = project.root / _SKILL_PATH + assert deployed.read_bytes() == skill.read_bytes() + assert not (project.root / ".agents/skills" / _SKILL_NAME).exists() + lock = LockFile.read(project.root / "apm.lock.yaml") + assert lock is not None + assert {record.locator.target for record in lock.deployment_ledger.records.values()} == { + "grok-cloud" + } + records = tuple( + record + for record in lock.deployment_ledger.records.values() + if record.locator.value == _SKILL_PATH + ) + assert len(records) == 1 + record = records[0] + assert record.locator.target == "grok-cloud" + assert record.locator.value == _SKILL_PATH + assert record.owners == (record.active_owner,) + assert record.active_owner in lock.dependencies + assert record.content_hash == f"sha256:{hashlib.sha256(skill.read_bytes()).hexdigest()}" + return scenario + + +@pytest.mark.parametrize("cold_cache", [False, True], ids=["warm", "cold"]) +def test_clean_configured_grok_cloud_audit( + tmp_path: Path, + apm_binary_path: Path, + cold_cache: bool, +) -> None: + """An unrelated CI directory cannot retarget a clean cloud installation.""" + scenario = _install_grok(tmp_path, apm_binary_path) + if cold_cache: + shutil.rmtree(scenario.project / "apm_modules") + payload = scenario.audit() + assert payload["passed"] is True + checks = {check["name"]: check for check in payload["checks"]} + assert checks["drift"]["passed"] is True + assert checks["deployment-ledger-owners"]["passed"] is True + assert checks["content-integrity"]["passed"] is True + + +@pytest.mark.parametrize( + ("mutation", "failed_check", "drift_kind"), + [ + ("content", "content-integrity", "modified"), + ("forged-hash", "drift", "modified"), + ("missing-claim", "drift", "unrecorded"), + ("invalid-owner", "deployment-ledger-owners", None), + ("missing-file", "deployed-files-present", "unintegrated"), + ("removed-source", "drift", "orphaned"), + ("disabled", "target-resolution", None), + ], +) +def test_configured_target_keeps_real_audit_failures( + tmp_path: Path, + apm_binary_path: Path, + mutation: str, + failed_check: str, + drift_kind: str | None, +) -> None: + """Configured intent must not depend on the integrity of installed claims.""" + scenario = _install_grok(tmp_path, apm_binary_path) + deployed = scenario.project / _SKILL_PATH + lock_path = scenario.project / "apm.lock.yaml" + if mutation in {"content", "forged-hash"}: + deployed.write_bytes(deployed.read_bytes() + b"\nUnexpected deployed edit.\n") + if mutation in {"forged-hash", "missing-claim", "invalid-owner"}: + document = load_yaml(lock_path) + for row in list(document["deployments"]): + if mutation == "missing-claim" and ( + row["value"] == _SKILL_PATH + or _SKILL_PATH.startswith(row["value"].rstrip("/") + "/") + ): + document["deployments"].remove(row) + continue + if row["value"] != _SKILL_PATH: + continue + if mutation == "forged-hash": + row["content_hash"] = f"sha256:{hashlib.sha256(deployed.read_bytes()).hexdigest()}" + else: + row["owners"] = ["departed-owner"] + row["active_owner"] = "departed-owner" + for dependency in document["dependencies"]: + if mutation == "forged-hash": + dependency["deployed_file_hashes"][_SKILL_PATH] = ( + f"sha256:{hashlib.sha256(deployed.read_bytes()).hexdigest()}" + ) + elif mutation == "missing-claim": + dependency["deployed_files"] = [ + path + for path in dependency["deployed_files"] + if path != _SKILL_PATH and not _SKILL_PATH.startswith(path.rstrip("/") + "/") + ] + dependency["deployed_file_hashes"].pop(_SKILL_PATH) + dump_yaml(document, lock_path) + elif mutation == "missing-file": + deployed.unlink() + elif mutation == "removed-source": + scenario.skill.unlink() + elif mutation == "disabled": + scenario.run("experimental", "disable", "grok-cloud") + + payload = scenario.audit(expected_exit=1) + checks = {check["name"]: check for check in payload["checks"]} + assert checks[failed_check]["passed"] is False + if drift_kind is not None: + assert f"{drift_kind}: {_SKILL_PATH}" in checks["drift"]["details"] + + +def test_target_contraction_keeps_old_claims_in_comparison( + tmp_path: Path, apm_binary_path: Path +) -> None: + """Old targets widen comparison coverage, not desired replay output.""" + scenario = _install_grok(tmp_path, apm_binary_path) + scenario.run( + "install", "--target", "grok-cloud,claude", "--no-policy", "--parallel-downloads", "0" + ) + payload = scenario.audit(expected_exit=1) + drift = next(check for check in payload["checks"] if check["name"] == "drift") + assert f"orphaned: .claude/skills/{_SKILL_NAME}/SKILL.md" in drift["details"] + assert not any(detail.startswith("unintegrated:") for detail in drift["details"]) + + +def test_manifest_beats_stale_saved_default(tmp_path: Path, apm_binary_path: Path) -> None: + """The validated manifest overrides a conflicting user default.""" + scenario = _install_grok(tmp_path, apm_binary_path) + manifest_path = scenario.project / "apm.yml" + manifest = load_yaml(manifest_path) + manifest["targets"] = ["grok-build"] + dump_yaml(manifest, manifest_path) + scenario.run("config", "set", "target", "claude") + scenario.run("install", "--no-policy", "--parallel-downloads", "0") + assert scenario.audit()["passed"] is True + + +def test_changed_default_audits_current_intent(tmp_path: Path, apm_binary_path: Path) -> None: + """Changing the default cannot silently bless the prior installed target.""" + scenario = _install_grok(tmp_path, apm_binary_path) + scenario.run("config", "set", "target", "claude") + payload = scenario.audit(expected_exit=1) + drift = next(check for check in payload["checks"] if check["name"] == "drift") + assert f"unintegrated: .claude/skills/{_SKILL_NAME}/SKILL.md" in drift["details"] + assert f"orphaned: {_SKILL_PATH}" in drift["details"] + + +def test_configured_grok_cloud_user_scope_audit(tmp_path: Path, apm_binary_path: Path) -> None: + """Global replay uses the user deployment root, without writing to HOME.""" + scenario = _install_grok(tmp_path, apm_binary_path) + scenario.run( + "install", + "--global", + str(scenario.skill.parents[2]), + "--target", + "grok-cloud", + "--no-policy", + "--parallel-downloads", + "0", + ) + home_skill = scenario.isolated.home / _SKILL_PATH + assert home_skill.read_bytes() == scenario.skill.read_bytes() + user = replace(scenario, project=scenario.isolated.config_root) + before_home = ArtifactSnapshot.capture(scenario.isolated.home) + assert user.audit()["passed"] is True + assert_unchanged(before_home, ArtifactSnapshot.capture(scenario.isolated.home)) diff --git a/tests/unit/install/test_audit_target_roots.py b/tests/unit/install/test_audit_target_roots.py new file mode 100644 index 0000000000..88d11dc114 --- /dev/null +++ b/tests/unit/install/test_audit_target_roots.py @@ -0,0 +1,126 @@ +"""Current-intent audit resolution is read-only and scope-aware.""" + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from apm_cli.install.audit_target_roots import AuditTargetError, resolve_audit_targets +from tests.utils.artifact_snapshot import ArtifactSnapshot, assert_unchanged + +pytestmark = pytest.mark.component + + +@pytest.mark.parametrize("user_scope", [False, True]) +def test_configured_experimental_target_is_read_only(tmp_path: Path, user_scope: bool) -> None: + """Resolve configured cloud profiles without materializing any target root.""" + project = tmp_path / "project" + project.mkdir() + before = ArtifactSnapshot.capture(tmp_path) + with ( + patch("apm_cli.config.get_install_target", return_value="grok-cloud") as config, + patch("apm_cli.core.experimental.is_enabled", return_value=True) as enabled, + patch.object(Path, "home", return_value=tmp_path / "home"), + ): + profiles = resolve_audit_targets(project, user_scope=user_scope) + assert tuple(profile.name for profile in profiles) == ("grok-cloud",) + assert profiles[0].root_dir == ".grok" + config.assert_called_once_with(create_config=False) + enabled.assert_called_once_with("grok_cloud", create_config=False) + assert_unchanged(before, ArtifactSnapshot.capture(tmp_path)) + + +@pytest.mark.parametrize( + "manifest", + [ + "targets: [grok-cloud]\n", + "targets: []\n", + "target: null\n", + "targets: [claude]\ntarget: copilot\n", + "- not-a-mapping\n", + "targets: [\n", + ], +) +def test_bad_manifest_never_falls_through_to_config(tmp_path: Path, manifest: str) -> None: + """Invalid declarations cannot silently widen to configuration or detection.""" + (tmp_path / "apm.yml").write_text(manifest, encoding="utf-8") + with ( + patch("apm_cli.config.get_install_target", return_value="claude") as config, + pytest.raises(AuditTargetError), + ): + resolve_audit_targets(tmp_path) + config.assert_not_called() + + +def test_absent_config_retains_legacy_audit_detection(tmp_path: Path) -> None: + """Do not import install-v2 ambiguity errors into existing audit fallback.""" + (tmp_path / ".github").mkdir() + (tmp_path / ".claude").mkdir() + with ( + patch("apm_cli.config.get_install_target", return_value=None), + patch("apm_cli.core.experimental.is_enabled", return_value=False), + ): + targets = resolve_audit_targets(tmp_path) + assert {target.name for target in targets} == {"copilot", "claude"} + + +def test_disabled_configured_target_fails_instead_of_empty_success(tmp_path: Path) -> None: + """Explicit current intent must be eligible for the requested scope.""" + with ( + patch("apm_cli.config.get_install_target", return_value="grok-cloud"), + patch("apm_cli.core.experimental.is_enabled", return_value=False), + pytest.raises(AuditTargetError, match="Cannot audit selected target"), + ): + resolve_audit_targets(tmp_path) + + +@pytest.mark.parametrize("configured", ["intellij", "vscode", "agents"]) +def test_runtime_alias_uses_canonical_primitive_profile(tmp_path: Path, configured: str) -> None: + """Use the owner's primitive projection, not an independent alias mapping.""" + with patch("apm_cli.config.get_install_target", return_value=configured): + targets = resolve_audit_targets(tmp_path) + assert tuple(target.name for target in targets) == ("copilot",) + + +def test_missing_config_is_not_created(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The real config reader must not initialize HOME during target discovery.""" + from apm_cli import config + + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", lambda: home) + monkeypatch.setattr(config, "_config_cache", None) + before = ArtifactSnapshot.capture(home) + assert resolve_audit_targets(tmp_path)[0].name == "copilot" + assert_unchanged(before, ArtifactSnapshot.capture(home)) + + +@pytest.mark.parametrize("claim_kind", ["file", "directory", "replaced-file"]) +def test_removed_target_claims_remain_in_comparison(tmp_path: Path, claim_kind: str) -> None: + """Legacy directory ownership also preserves old-target drift coverage.""" + from apm_cli.deps.lockfile import LockedDependency, LockFile + from apm_cli.install.drift import DriftFinding, diff_scratch_against_project + from apm_cli.integration.targets import KNOWN_TARGETS + + project = tmp_path / "project" + scratch = tmp_path / "scratch" + scratch.mkdir() + relative = ".claude/skills/old/SKILL.md" + deployed = project / relative + deployed.parent.mkdir(parents=True) + deployed.write_text("Previously deployed skill\n", encoding="utf-8") + claim = relative if claim_kind == "file" else ".claude/skills/old" + hashes = {claim: "sha256:" + "a" * 64} if claim_kind == "replaced-file" else {} + lock = LockFile( + dependencies={ + "owner/pkg": LockedDependency( + repo_url="owner/pkg", deployed_files=[claim], deployed_file_hashes=hashes + ) + } + ) + findings = diff_scratch_against_project(scratch, project, lock, [KNOWN_TARGETS["grok-cloud"]]) + assert findings == ( + [] + if claim_kind == "replaced-file" + else [DriftFinding(path=relative, kind="orphaned", package="owner/pkg")] + ) diff --git a/tests/unit/install/test_drift_detection.py b/tests/unit/install/test_drift_detection.py index 0b02b38c51..c2eb142aba 100644 --- a/tests/unit/install/test_drift_detection.py +++ b/tests/unit/install/test_drift_detection.py @@ -26,6 +26,7 @@ from unittest.mock import MagicMock, patch import pytest +import yaml from apm_cli.deps.lockfile import LockedDependency, LockFile from apm_cli.install.drift import ( @@ -326,12 +327,11 @@ def test_apm_yml_no_target_returns_none(self, tmp_path: Path) -> None: (tmp_path / "apm.yml").write_text("name: pkg\n", encoding="utf-8") assert _read_apm_yml_target(tmp_path) is None - def test_apm_yml_unreadable_returns_none(self, tmp_path: Path) -> None: + def test_apm_yml_unreadable_fails_closed(self, tmp_path: Path) -> None: p = tmp_path / "apm.yml" p.write_bytes(b"\xff not yaml [[[") - # Should not raise - result = _read_apm_yml_target(tmp_path) - assert result is None + with pytest.raises(yaml.YAMLError, match="bounded YAML parse failed"): + _read_apm_yml_target(tmp_path) def test_apm_yml_with_singular_target_returns_list(self, tmp_path: Path) -> None: # Singular 'target: copilot' form -- returns a one-element list. @@ -347,14 +347,16 @@ def test_apm_yml_with_targets_list_returns_list(self, tmp_path: Path) -> None: result = _read_apm_yml_target(tmp_path) assert result == ["claude", "codex"] - def test_parse_targets_field_exception_returns_none(self, tmp_path: Path) -> None: + def test_parse_targets_field_exception_propagates(self, tmp_path: Path) -> None: (tmp_path / "apm.yml").write_text("name: pkg\ntarget: copilot\n", encoding="utf-8") - with patch( - "apm_cli.core.apm_yml.parse_targets_field", - side_effect=ValueError("bad"), + with ( + patch( + "apm_cli.core.apm_yml.parse_targets_field", + side_effect=ValueError("bad"), + ), + pytest.raises(ValueError, match="bad"), ): - result = _read_apm_yml_target(tmp_path) - assert result is None + _read_apm_yml_target(tmp_path) # --------------------------------------------------------------------------- diff --git a/tests/unit/install/test_drift_phase3.py b/tests/unit/install/test_drift_phase3.py index a53a70758d..0cfeada838 100644 --- a/tests/unit/install/test_drift_phase3.py +++ b/tests/unit/install/test_drift_phase3.py @@ -26,6 +26,7 @@ from unittest.mock import MagicMock, patch import pytest +import yaml from apm_cli.deps.lockfile import LockedDependency, LockFile from apm_cli.install.drift import ( @@ -304,12 +305,11 @@ def test_apm_yml_no_target_returns_none(self, tmp_path: Path) -> None: (tmp_path / "apm.yml").write_text("name: pkg\n", encoding="utf-8") assert _read_apm_yml_target(tmp_path) is None - def test_apm_yml_unreadable_returns_none(self, tmp_path: Path) -> None: + def test_apm_yml_unreadable_fails_closed(self, tmp_path: Path) -> None: p = tmp_path / "apm.yml" p.write_bytes(b"\xff not yaml [[[") - # Should not raise - result = _read_apm_yml_target(tmp_path) - assert result is None + with pytest.raises(yaml.YAMLError, match="bounded YAML parse failed"): + _read_apm_yml_target(tmp_path) def test_apm_yml_with_singular_target_returns_list(self, tmp_path: Path) -> None: # Singular 'target: copilot' form -- returns a one-element list. @@ -317,14 +317,16 @@ def test_apm_yml_with_singular_target_returns_list(self, tmp_path: Path) -> None result = _read_apm_yml_target(tmp_path) assert result == ["copilot"] - def test_parse_targets_field_exception_returns_none(self, tmp_path: Path) -> None: + def test_parse_targets_field_exception_propagates(self, tmp_path: Path) -> None: (tmp_path / "apm.yml").write_text("name: pkg\ntarget: copilot\n", encoding="utf-8") - with patch( - "apm_cli.core.apm_yml.parse_targets_field", - side_effect=ValueError("bad"), + with ( + patch( + "apm_cli.core.apm_yml.parse_targets_field", + side_effect=ValueError("bad"), + ), + pytest.raises(ValueError, match="bad"), ): - result = _read_apm_yml_target(tmp_path) - assert result is None + _read_apm_yml_target(tmp_path) # --------------------------------------------------------------------------- From 1d4ff88f8fa33a5ab2a0b8466fe37cef324bb001 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sun, 6 Sep 2026 13:18:40 +0200 Subject: [PATCH 09/34] fix(audit): keep native replay and discovery read-only Reject malformed current target intent, isolate or refuse native replay, retain contracted native claims for comparison, and keep audit configuration reads non-creating. Add real CLI boundary controls without modifying published v0.1 conformance. Refs #2816, #2813. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/src/content/docs/reference/cli/audit.md | 20 +- docs/src/content/docs/reference/cli/config.md | 2 +- .../.apm/skills/apm-usage/commands.md | 43 +- .../checks/registry_owner_guards.py | 29 +- src/apm_cli/config.py | 28 +- src/apm_cli/core/target_detection.py | 7 +- src/apm_cli/install/audit_replay.py | 2 +- src/apm_cli/install/audit_target_roots.py | 64 ++- src/apm_cli/install/drift.py | 9 +- .../integration/copilot_cowork_paths.py | 2 +- src/apm_cli/integration/mcp_config_view.py | 1 + src/apm_cli/policy/_shared.py | 2 +- src/apm_cli/policy/ci_checks.py | 12 +- src/apm_cli/security/file_scanner.py | 4 +- ...architecture_confirmed_bypass_mutations.py | 28 ++ .../test_audit_current_intent_contract.py | 388 ++++++++++++++++++ tests/unit/install/test_audit_target_roots.py | 4 +- tests/unit/test_config.py | 13 + 18 files changed, 620 insertions(+), 38 deletions(-) create mode 100644 tests/integration/test_audit_current_intent_contract.py diff --git a/docs/src/content/docs/reference/cli/audit.md b/docs/src/content/docs/reference/cli/audit.md index eb143554e8..2f9e71297a 100644 --- a/docs/src/content/docs/reference/cli/audit.md +++ b/docs/src/content/docs/reference/cli/audit.md @@ -182,16 +182,28 @@ For the full workflow, see [Enforce in CI](../../../enterprise/enforce-in-ci/). Audit evaluates **current target intent**: `apm.yml target(s)` first, then [`apm config set target`](../config/), then existing directory detection. It does not reconstruct an earlier one-shot `apm install --target` override. +Audit rejects a malformed saved target only when that fallback is selected; +a valid manifest target wins over irrelevant stale invalid configuration. Keep the intended target in the manifest or saved configuration; experimental targets such as `grok-cloud` use the saved configuration because manifest target declarations do not accept experimental names. Changing that intent changes the expected output. Old recorded deployments remain in the comparison, and missing ownership does not remove source-derived -expectations. Experimental targets still require enablement and their runtime -prerequisites; an unavailable selected target fails CI as `target-resolution`, -rather than passing an empty replay. Audit reads configuration without creating -or updating it. +expectations. Recorded native roots are comparison-only, not replay authority; +an unavailable former root fails comparison instead of passing. Filesystem-backed +native targets retain their scoped roots and layout, with replay destinations +rebased into scratch. + +Experimental targets still require enablement and runtime prerequisites; +an unavailable selected target fails CI as `target-resolution`. Saving a target +name does not guarantee replay support: native nonfilesystem targets without +an isolated filesystem backend (currently `copilot-app`) produce an explicit +scratch replay failure. Audit does not invoke their live workflow writer or +modify their database or sidecars. + +Audit leaves absent user configuration absent and existing configuration +unchanged, including during package parsing, MCP inspection, and content scanning. The default audit replays the install pipeline into a scratch tree and diffs the result against the working tree. It catches hand-edits, missing diff --git a/docs/src/content/docs/reference/cli/config.md b/docs/src/content/docs/reference/cli/config.md index d140988a92..2899e79a93 100644 --- a/docs/src/content/docs/reference/cli/config.md +++ b/docs/src/content/docs/reference/cli/config.md @@ -247,7 +247,7 @@ See [External scanners](../../../integrations/external-scanners/). - **Location:** `~/.apm/config.json` - **Format:** JSON object, one entry per stored key. -- **Created on first read** with `{"default_client": "vscode"}`. Hand-editing is supported but `apm config set` is preferred -- it validates input and normalizes paths. +- **Created on first read** with `{"default_client": "vscode"}`, except during [audit](../audit/#drift-detection), which leaves absent configuration absent. Prefer `apm config set` over hand-editing: it validates input and normalizes paths. Internal JSON keys use snake_case (`auto_integrate`, `install_target`, `self_update_channel`, `self_update_install_dir`, `temp_dir`, `allow_protocol_fallback`, `prefer_ssh`, `copilot_cowork_skills_dir`); CLI keys use kebab-case or dotted namespaces (the CLI `target` key is stored as `install_target`). The CLI translates between the two. diff --git a/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md index bf8cffe0b5..490fd6e6b2 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/commands.md +++ b/packages/apm-guide/.apm/skills/apm-usage/commands.md @@ -224,22 +224,43 @@ descendants, are skipped. ## Security and audit Audit replays current target intent: manifest `target(s)` > saved `apm config -set target` > existing directory detection. An unsaved earlier `install ---target` override is not recovered from ownership records. Experimental targets -require their prerequisites and use saved configuration, not experimental -manifest declarations. Changing targets does not exempt old claimed files or -missing ownership from drift detection. Configuration is read-only during audit. +set target` > existing directory detection. A selected malformed saved target +fails resolution; a valid manifest target overrides irrelevant invalid saved +configuration. Audit does not recover unsaved historical `install --target` +overrides or create missing user configuration. + +Saved target names do not guarantee scratch replay support. Native nonfilesystem +targets without an isolated filesystem backend (currently `copilot-app`) fail +replay explicitly without invoking the live workflow writer or changing its +database or sidecars. Filesystem-backed native layouts are rebased into scratch; +old native claims remain comparison-only, and unavailable former roots fail +closed. Missing ownership never removes source-derived expectations. See the +[audit reference](https://microsoft.github.io/apm/reference/cli/audit/#drift-detection) +for target prerequisites and replay boundaries. | Command | Purpose | Key flags | |---------|---------|-----------| | `apm audit [PKG]` | Scan installed primitives for hidden Unicode, drift, and lockfile/policy violations | `--file PATH`, `--strip`, `--dry-run`, `-v`, `-f [text\|json\|sarif\|md]`, `-o PATH`, `--ci`, `--policy SOURCE`, `--no-cache`, `--no-fail-fast`, `--no-drift`, `--external NAME` (experimental; ingest a third-party SARIF scanner, e.g. `skillspector`), `--external-sarif PATH`, `--external-llm/--no-external-llm`, `--external-args TEXT` | -`apm audit` runs **drift detection by default** (issue #1071). It replays `apm install` into a temporary scratch tree and diffs the result against your working tree. Catches three failure modes: (1) `.apm/` source added without re-running `apm install`, (2) hand-edits to deployed files that diverge from canonical source, (3) orphan files left after their source was removed. The scan is read-only -- never writes to your project, lockfile, or live `apm_modules/`. Build IDs, CRLF line endings, and BOMs are normalized away so they cannot trigger false positives. Bare `apm audit` still uses the warmed local cache and skips with an informational message when the cache is absent. `apm audit --ci` is stricter: when `apm_modules/` is missing but `apm.lock.yaml` is present, it self-hydrates a lock-pinned scratch install for `config-consistency` and drift without touching the checkout. That closes the setup-only CI gap for repos that commit deployed files. Repos that gitignore deployed outputs still need those files present on disk for `deployed-files-present`, so keep the full-install CI pattern there. Use `--no-drift` to opt out (e.g. fast inner loops); the flag is mutually exclusive with `--strip`/`--file`. Ordinary drift remains advisory in bare audit and fails only in `--ci` mode or when policy promotes it. A stale canonical deployment owner is different: `deployment-ledger-owners` is a hard integrity failure in both modes, exits 1, names the owner and path in text/JSON/SARIF, and blocks `--strip`. Remediate it with `apm prune`, then rerun `apm audit`. Drift output is integrated into JSON (top-level `drift` key) and SARIF (rule IDs `apm/drift/` where kind is `modified`/`unintegrated`/`orphaned`). -`apm audit` runs **drift detection by default** (issue #1071). It replays `apm install` cache-only into a temporary scratch tree and diffs the result against your working tree. It catches four failure modes: (1) `.apm/` source added without re-running `apm install`, (2) hand-edits to deployed files that diverge from canonical source, (3) orphan files left after their source was removed, and (4) `unrecorded` files that install deploys but no lockfile entry claims. The scan is read-only -- never writes to your project, lockfile, or `apm_modules/`. Build IDs, CRLF line endings, and BOMs are normalized away so they cannot trigger false positives. If the install cache has not been warmed (e.g. a fresh checkout before the first `apm install`), the drift check is skipped with an informational message and can still exit 0; run `apm install` before relying on drift until cold-cache replay lands. Use `--no-drift` to opt out with reduced coverage; the flag is mutually exclusive with `--strip`/`--file`. Ordinary drift remains advisory in bare audit and fails in `--ci` mode. Remediate `unrecorded` with `apm install`, then commit the regenerated `apm.lock.yaml`. A stale canonical deployment owner is different: `deployment-ledger-owners` is a hard integrity failure in both modes, exits 1, names the owner and path in text/JSON/SARIF, and blocks `--strip`. Remediate it with `apm prune`, then rerun `apm audit`. Drift output is integrated into JSON (top-level `drift` key) and SARIF (rule IDs `apm/drift/` where kind is `modified`/`unintegrated`/`orphaned`/`unrecorded`). -`apm audit` runs **drift detection by default** (issue #1071). It replays `apm install` cache-only into a temporary scratch tree and diffs the result against your working tree. It catches four failure modes: (1) `.apm/` source added without re-running `apm install`, (2) hand-edits to deployed files that diverge from canonical source, (3) orphan files left after their source was removed, and (4) `unrecorded` files that install deploys but no lockfile entry claims. The scan is read-only -- never writes to your project, lockfile, or `apm_modules/`. Build IDs, CRLF line endings, and BOMs are normalized away so they cannot trigger false positives. If the install cache has not been warmed (e.g. a fresh checkout before the first `apm install`), the drift check is skipped with an informational message and can still exit 0; run `apm install` before relying on drift until cold-cache replay lands. Use `--no-drift` to opt out with reduced coverage; the flag is mutually exclusive with `--strip`/`--file`. Drift is advisory in bare audit by default unless policy enables `security.audit.fail_on_drift`; `--ci` always gates on drift. Remediate `unrecorded` with `apm install`, then commit the regenerated `apm.lock.yaml`. A stale canonical deployment owner is different: `deployment-ledger-owners` is a hard integrity failure in both modes, exits 1, names the owner and path in text/JSON/SARIF, and blocks `--strip`. Remediate it with `apm prune`, then rerun `apm audit`. Drift output is integrated into JSON (top-level `drift` key) and SARIF (rule IDs `apm/drift/` where kind is `modified`/`unintegrated`/`orphaned`/`unrecorded`). -`apm audit` runs **drift detection by default** (issue #1071). It replays `apm install` cache-only into a temporary scratch tree and diffs the result against your working tree. It catches four failure modes: (1) `.apm/` source added without re-running `apm install`, (2) hand-edits to deployed files that diverge from canonical source, (3) orphan files left after their source was removed, and (4) `unrecorded` files that install deploys but no lockfile entry claims. The scan is read-only -- never writes to your project, lockfile, or `apm_modules/`. Build IDs, CRLF line endings, and BOMs are normalized away so they cannot trigger false positives. - -If the install cache has not been warmed (e.g. a fresh checkout before the first `apm install`), the drift check is skipped with an informational message and can still exit 0; run `apm install` before relying on drift until cold-cache replay lands. Use `--no-drift` to opt out with reduced coverage; the flag is mutually exclusive with `--strip`/`--file`. Drift is advisory in bare audit by default unless policy enables `security.audit.fail_on_drift`; `--ci` always gates on drift. Remediate `unrecorded` with `apm install`, then commit the regenerated `apm.lock.yaml`. A stale canonical deployment owner is different: `deployment-ledger-owners` is a hard integrity failure in both modes, exits 1, names the owner and path in text/JSON/SARIF, and blocks `--strip`. Remediate it with `apm prune`, then rerun `apm audit`. Drift output is integrated into JSON (top-level `drift` key) and SARIF (rule IDs `apm/drift/` where kind is `modified`/`unintegrated`/`orphaned`/`unrecorded`). +Drift detection runs by default without modifying the project, lockfile, or live +`apm_modules/`. It compares replayed output for `modified`, `unintegrated`, +`orphaned`, and `unrecorded` files, normalizing build IDs, CRLF, and BOMs. +Bare audit uses the local cache and skips drift on a cache miss. With a lockfile, +`apm audit --ci` self-hydrates a lock-pinned scratch install for +`config-consistency` and drift when `apm_modules/` is absent. Gitignored deployed +outputs must still exist for `deployed-files-present`. + +`--no-drift` skips replay with reduced coverage and cannot accompany `--strip` +or `--file`. Ordinary drift is advisory in bare audit unless policy enables +`security.audit.fail_on_drift`; `--ci` gates on drift. For `unrecorded` files, +run `apm install` and commit the regenerated `apm.lock.yaml`. This does not +provide a missing native replay backend. JSON reports use the top-level `drift` +key; SARIF uses `apm/drift/` rule IDs. + +Invalid canonical deployment owners remain a hard `deployment-ledger-owners` +failure in both modes: exit 1, owner/path diagnostics, and `--strip` blocked. +Run `apm prune`, then rerun `apm audit`; invalid owner records never authorize +deleting files. **External scanners (experimental, behind `apm experimental enable external-scanners`).** `--external NAME` runs a third-party SARIF scanner (e.g. `skillspector`) and merges its findings. `--external-llm/--no-external-llm` toggles LLM-powered analysis (default off; sends scanned content to a third-party API, so APM prints a `[!]` egress banner and forwards `OPENAI_API_KEY`/`NVIDIA_INFERENCE_KEY` only when on). `--external-args TEXT` is a single shlex-split string of extra scanner flags, validated against a per-adapter allowlist -- non-allowlisted flags, secret-looking flags, and out-of-cwd paths are rejected fail-closed. `--external-llm`/`--external-args` without `--external` is a usage error (exit 2). Scanner configuration or infrastructure errors (feature disabled, scanner not found, malformed SARIF) exit **3**. Persist defaults with `apm config set external..llm true` and `apm config set external..args -- "--model gpt-4o"`. Precedence: CLI > config > policy floor. diff --git a/scripts/architecture_linter/checks/registry_owner_guards.py b/scripts/architecture_linter/checks/registry_owner_guards.py index ff89786c18..56db0c63dc 100644 --- a/scripts/architecture_linter/checks/registry_owner_guards.py +++ b/scripts/architecture_linter/checks/registry_owner_guards.py @@ -335,15 +335,17 @@ def _check_install_target_selection(provider: FactsProvider) -> Iterable[Violati def _check_audit_target_selection(provider: FactsProvider, rule_id: str) -> Iterable[Violation]: """Audit adapts the canonical decision and never redetects scratch targets.""" adapter = "src/apm_cli/install/audit_target_roots.py" + native_discovery = "src/apm_cli/integration/copilot_cowork_paths.py" required = { adapter: { "resolve_effective_target_decision", "read_declared_target_names", "resolve_targets", }, - "src/apm_cli/install/drift.py": {"resolve_audit_targets"}, + "src/apm_cli/install/drift.py": {"resolve_audit_targets", "replay_target"}, "src/apm_cli/install/audit_replay.py": {"resolve_audit_targets"}, - "src/apm_cli/policy/ci_checks.py": {"resolve_audit_targets"}, + "src/apm_cli/policy/ci_checks.py": {"resolve_audit_targets", "audit_comparison_targets"}, + native_discovery: {"get_copilot_cowork_skills_dir"}, } for path, expected_calls in required.items(): _facts, failures = checked_facts(provider, path, rule_id, require_python=True) @@ -374,9 +376,10 @@ def _check_audit_target_selection(provider: FactsProvider, rule_id: str) -> Iter "audit consumers must use resolve_audit_targets", line=call.lineno, ) - if path == adapter and call.func.id in { + if path in {adapter, native_discovery} and call.func.id in { "resolve_effective_target_decision", "resolve_targets", + "get_copilot_cowork_skills_dir", }: keywords = {keyword.arg: keyword.value for keyword in call.keywords} value = keywords.get("create_config") @@ -387,6 +390,26 @@ def _check_audit_target_selection(provider: FactsProvider, rule_id: str) -> Iter "audit target resolution must not create config", line=call.lineno, ) + if call.func.id == "resolve_effective_target_decision": + strict = keywords.get("strict_config") + if not isinstance(strict, ast.Constant) or strict.value is not True: + yield violation( + rule_id, + path, + "audit target resolution must reject invalid saved configuration", + line=call.lineno, + ) + if path == adapter: + for definition in direct_definitions(index, "replay_target", kinds=FUNCTION_NODES): + if not any( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "_require_filesystem_replay" + for node in index.own_scope(definition) + ): + yield violation( + rule_id, path, "audit projection must reject unsafe native replay" + ) if path != adapter: for node in index.walk(index.root): if ( diff --git a/src/apm_cli/config.py b/src/apm_cli/config.py index bf62234e17..d8d73e8da0 100644 --- a/src/apm_cli/config.py +++ b/src/apm_cli/config.py @@ -206,21 +206,34 @@ def unset_temp_dir() -> None: _unset_config_key("temp_dir") -def get_install_target(*, create_config: bool = True) -> str | list[str] | None: +def get_install_target( + *, create_config: bool = True, strict: bool = False +) -> str | list[str] | None: """Get the configured default target used by ``apm install``. Args: create_config: When false, do not create a missing user config file. + strict: Reject invalid present values rather than treating them as unset. Returns: Parsed target value from config, or ``None`` when unset/invalid. """ from apm_cli.core.target_detection import parse_target_field - value = get_config(create=create_config).get(_INSTALL_TARGET_KEY) + settings = get_config(create=create_config) + if _INSTALL_TARGET_KEY not in settings: + return None try: - return parse_target_field(value) - except ValueError: + parsed = parse_target_field(settings[_INSTALL_TARGET_KEY]) + if strict and parsed is None: + raise ValueError("target value must not be null") + return parsed + except ValueError as exc: + if strict: + raise ValueError( + "Invalid saved target configuration. Correct 'apm config set target' " + f"or use 'apm config unset target': {exc}" + ) from exc return None @@ -489,13 +502,16 @@ def get_apm_protocol_pref( # --------------------------------------------------------------------------- -def get_copilot_cowork_skills_dir() -> str | None: +def get_copilot_cowork_skills_dir(*, create_config: bool = True) -> str | None: """Get the configured cowork skills directory. + Args: + create_config: When false, leave missing configuration absent during discovery. + Returns: The stored ``copilot_cowork_skills_dir`` config value, or ``None`` if not set. """ - return get_config().get("copilot_cowork_skills_dir") + return get_config(create=create_config).get("copilot_cowork_skills_dir") def set_copilot_cowork_skills_dir(path: str) -> None: diff --git a/src/apm_cli/core/target_detection.py b/src/apm_cli/core/target_detection.py index f33ae3451d..24af4a577e 100644 --- a/src/apm_cli/core/target_detection.py +++ b/src/apm_cli/core/target_detection.py @@ -930,6 +930,7 @@ def resolve_effective_target_decision( user_scope: bool = False, auto_detect: bool = True, create_config: bool = True, + strict_config: bool = False, ) -> EffectiveTargetDecision: """Choose the effective install target once using the public precedence. @@ -947,7 +948,11 @@ def resolve_effective_target_decision( from apm_cli.config import get_install_target - configured_target = get_install_target(create_config=create_config) + configured_target = ( + get_install_target(create_config=create_config, strict=True) + if strict_config + else get_install_target(create_config=create_config) + ) if configured_target is not None: return EffectiveTargetDecision(configured_target, "apm config target") diff --git a/src/apm_cli/install/audit_replay.py b/src/apm_cli/install/audit_replay.py index ea8efe02e3..acefe03f01 100644 --- a/src/apm_cli/install/audit_replay.py +++ b/src/apm_cli/install/audit_replay.py @@ -76,7 +76,7 @@ def prepare_ci_audit_replay( f"lockfile not found at {lockfile_path}; run 'apm install' to generate it" ) - manifest = APMPackage.from_apm_yml(project_root / "apm.yml") + manifest = APMPackage.from_apm_yml(project_root / "apm.yml", create_config=False) lockfile = LockFile.read(lockfile_path) if lockfile is None: raise CiAuditReplayError(f"lockfile at {lockfile_path} is empty or unreadable") diff --git a/src/apm_cli/install/audit_target_roots.py b/src/apm_cli/install/audit_target_roots.py index 65f277556c..173b9700a8 100644 --- a/src/apm_cli/install/audit_target_roots.py +++ b/src/apm_cli/install/audit_target_roots.py @@ -4,15 +4,19 @@ from dataclasses import replace from pathlib import Path +from typing import TYPE_CHECKING import click import yaml -from apm_cli.integration.targets import TargetProfile, resolve_targets +from apm_cli.integration.targets import KNOWN_TARGETS, TargetProfile, resolve_targets from apm_cli.utils.path_security import PathTraversalError, ensure_path_within _EXTERNAL_REPLAY_ROOT = ".apm-audit-targets" +if TYPE_CHECKING: + from apm_cli.deps.lockfile import LockFile + class AuditTargetError(ValueError): """Current audit target intent cannot be resolved safely.""" @@ -33,6 +37,7 @@ def resolve_audit_targets( user_scope=user_scope, auto_detect=False, create_config=False, + strict_config=True, ) selected_targets = decision.canonical_targets targets = resolve_targets( @@ -54,17 +59,61 @@ def resolve_audit_targets( return tuple(targets) -def replay_target(target: TargetProfile) -> TargetProfile: +def _require_filesystem_replay(target: TargetProfile) -> None: + """Native runtime records without a filesystem decoder cannot use scratch.""" + if target.external_locator_encoder is not None and target.external_locator_decoder is None: + raise AuditTargetError( + f"Target '{target.name}' has no isolated filesystem scratch replay backend. " + "Audit cannot safely replay this native runtime; live state was not modified." + ) + + +def replay_target(target: TargetProfile, scratch_root: Path) -> TargetProfile: """Return a scratch-contained profile for replay-only integration.""" + _require_filesystem_replay(target) if target.managed_deploy_root is None: return target return replace( target, root_dir=f"{_EXTERNAL_REPLAY_ROOT}/{target.name}", - resolved_deploy_root=None, + resolved_deploy_root=( + external_replay_root(scratch_root, target) + if target.resolved_deploy_root is not None + else None + ), ) +def audit_comparison_targets( + lockfile: LockFile, selected: tuple[TargetProfile, ...], *, user_scope: bool +) -> tuple[TargetProfile, ...]: + """Retain recorded native roots for comparison without authorizing replay.""" + from apm_cli.core.deployment_ledger import DeploymentLedgerCodec + + ledger = DeploymentLedgerCodec.from_lockfile(lockfile) + names = {record.locator.target for record in ledger.records.values()} + claims = DeploymentLedgerCodec.legacy_deployed_file_claims(lockfile) + result = {target.name: target for target in selected} + for name, profile in KNOWN_TARGETS.items(): + if name in result or not ( + name in names or any(path.startswith(profile.lockfile_uri_schemes) for path in claims) + ): + continue + _require_filesystem_replay(profile) + try: + scoped = profile.for_scope(user_scope=user_scope) + except (OSError, ValueError) as exc: + raise AuditTargetError(f"Cannot compare recorded target '{name}': {exc}") from exc + if scoped is None or (profile.lockfile_uri_schemes and scoped.managed_deploy_root is None): + raise AuditTargetError( + f"Cannot compare recorded target '{name}': its deployment root is unavailable. " + "Restore the runtime root or reconcile the recorded ownership." + ) + if scoped.managed_deploy_root is not None: + result[name] = scoped + return tuple(result.values()) + + def external_replay_root(scratch_root: Path, target: TargetProfile) -> Path: """Return the scratch projection root for an external target.""" return scratch_root / _EXTERNAL_REPLAY_ROOT / target.name @@ -73,6 +122,15 @@ def external_replay_root(scratch_root: Path, target: TargetProfile) -> Path: def external_target_relative_roots(target: TargetProfile) -> set[str]: """Return bounded paths governed below an external target root.""" roots: set[str] = set() + if "skills" in target.primitives: + from apm_cli.integration.skill_integrator import SkillIntegrator + + deploy_root = target.managed_deploy_root + if deploy_root is not None: + skills_root = SkillIntegrator._target_skills_root(target, deploy_root) + roots.add( + ensure_path_within(skills_root, deploy_root).relative_to(deploy_root).as_posix() + ) for mapping in target.primitives.values(): if mapping.deploy_root: deploy_root = Path(mapping.deploy_root) diff --git a/src/apm_cli/install/drift.py b/src/apm_cli/install/drift.py index d94e3de19d..59065efc73 100644 --- a/src/apm_cli/install/drift.py +++ b/src/apm_cli/install/drift.py @@ -433,7 +433,7 @@ def _build_package_info( apm_yml = install_path / "apm.yml" if apm_yml.exists(): try: - pkg = APMPackage.from_apm_yml(apm_yml, source_path=install_path) + pkg = APMPackage.from_apm_yml(apm_yml, source_path=install_path, create_config=False) except Exception: pkg = APMPackage( name=install_path.name, @@ -559,7 +559,10 @@ def run_replay(config: ReplayConfig, logger: CheckLogger) -> Path: if config.resolved_targets is not None else resolve_audit_targets(project_root, user_scope=config.user_scope) ) - targets = [replay_target(target) for target in _filter_targets(live_targets, config.targets)] + targets = [ + replay_target(target, scratch_root) + for target in _filter_targets(live_targets, config.targets) + ] registries: dict[str, str] | None = None downloader = None registry_resolver = None @@ -572,7 +575,7 @@ def run_replay(config: ReplayConfig, logger: CheckLogger) -> Path: downloader = GitHubPackageDownloader(auth_resolver=AuthResolver()) apm_yml = project_root / "apm.yml" if apm_yml.exists(): - manifest = APMPackage.from_apm_yml(apm_yml) + manifest = APMPackage.from_apm_yml(apm_yml, create_config=False) registries = getattr(manifest, "registries", None) or {} if registries: registry_resolver = RegistryPackageResolver(registries) diff --git a/src/apm_cli/integration/copilot_cowork_paths.py b/src/apm_cli/integration/copilot_cowork_paths.py index 1d416058af..f89bf59733 100644 --- a/src/apm_cli/integration/copilot_cowork_paths.py +++ b/src/apm_cli/integration/copilot_cowork_paths.py @@ -111,7 +111,7 @@ def resolve_copilot_cowork_skills_dir() -> Path | None: # --- persisted config value --- from apm_cli.config import get_copilot_cowork_skills_dir - config_value = get_copilot_cowork_skills_dir() + config_value = get_copilot_cowork_skills_dir(create_config=False) if config_value: from apm_cli.utils.path_security import ( PathTraversalError, diff --git a/src/apm_cli/integration/mcp_config_view.py b/src/apm_cli/integration/mcp_config_view.py index 2f776a8bfc..7c0d9f8851 100644 --- a/src/apm_cli/integration/mcp_config_view.py +++ b/src/apm_cli/integration/mcp_config_view.py @@ -336,6 +336,7 @@ def _collect_locked_dependencies( package = APMPackage.from_apm_yml( manifest_path, source_path=manifest_path.parent, + create_config=False, ) except (OSError, ValueError, UnicodeError) as exc: problems.append( diff --git a/src/apm_cli/policy/_shared.py b/src/apm_cli/policy/_shared.py index 052875f35c..30853252a1 100644 --- a/src/apm_cli/policy/_shared.py +++ b/src/apm_cli/policy/_shared.py @@ -27,7 +27,7 @@ def _parse_apm_yml_safe(apm_yml_path: Path, result) -> object | None: try: clear_apm_yml_cache() - return APMPackage.from_apm_yml(apm_yml_path) + return APMPackage.from_apm_yml(apm_yml_path, create_config=False) except (ValueError, yaml.YAMLError, OSError) as exc: result.checks.append( CheckResult( diff --git a/src/apm_cli/policy/ci_checks.py b/src/apm_cli/policy/ci_checks.py index 18f1a1ddbc..d3df35ef6e 100644 --- a/src/apm_cli/policy/ci_checks.py +++ b/src/apm_cli/policy/ci_checks.py @@ -726,6 +726,7 @@ def _check_drift( from ..deps.path_anchoring import LocalResolutionError from ..install.audit_target_roots import ( AuditTargetError, + audit_comparison_targets, external_replay_root, resolve_audit_targets, ) @@ -819,7 +820,16 @@ def _check_drift( project_targets, tracked_files=tracked_files, ) - for target in resolved_targets: + try: + comparison_targets = audit_comparison_targets( + lockfile, tuple(resolved_targets), user_scope=user_scope + ) + except AuditTargetError as exc: + return ( + CheckResult(name="drift", passed=False, message=f"drift comparison failed: {exc}"), + findings, + ) + for target in comparison_targets: live_root = target.managed_deploy_root if live_root is None: continue diff --git a/src/apm_cli/security/file_scanner.py b/src/apm_cli/security/file_scanner.py index d97b897227..581a22aa36 100644 --- a/src/apm_cli/security/file_scanner.py +++ b/src/apm_cli/security/file_scanner.py @@ -146,7 +146,9 @@ def _scan_deployed_trees( from ..install.manifest_reconcile import install_governance from ..integration.targets import resolve_targets - file_prefixes, _uri_schemes = install_governance(resolve_targets(project_root)) + file_prefixes, _uri_schemes = install_governance( + resolve_targets(project_root, create_config=False) + ) result = _empty_scan() for rel_path in _minimal_governed_prefixes(file_prefixes): diff --git a/tests/integration/test_architecture_confirmed_bypass_mutations.py b/tests/integration/test_architecture_confirmed_bypass_mutations.py index f94effff4a..e4a8ebe397 100644 --- a/tests/integration/test_architecture_confirmed_bypass_mutations.py +++ b/tests/integration/test_architecture_confirmed_bypass_mutations.py @@ -46,6 +46,34 @@ class BypassMutation: new="create_config=True", replace_all=True, ), + BypassMutation( + name="audit-strict-saved-target", + rule_id="registry_delegation.install_target_selection", + path="src/apm_cli/install/audit_target_roots.py", + old="strict_config=True", + new="strict_config=False", + ), + BypassMutation( + name="audit-native-writer-admission", + rule_id="registry_delegation.install_target_selection", + path="src/apm_cli/install/audit_target_roots.py", + old=" _require_filesystem_replay(target)\n", + new=" pass\n", + ), + BypassMutation( + name="audit-contracted-native-comparison", + rule_id="registry_delegation.install_target_selection", + path="src/apm_cli/policy/ci_checks.py", + old="comparison_targets = audit_comparison_targets(", + new="comparison_targets = private_comparison_targets(", + ), + BypassMutation( + name="audit-native-discovery-read-only", + rule_id="registry_delegation.install_target_selection", + path="src/apm_cli/integration/copilot_cowork_paths.py", + old="get_copilot_cowork_skills_dir(create_config=False)", + new="get_copilot_cowork_skills_dir(create_config=True)", + ), BypassMutation( name="audit-warm-replay-private-targets", rule_id="registry_delegation.install_target_selection", diff --git a/tests/integration/test_audit_current_intent_contract.py b/tests/integration/test_audit_current_intent_contract.py new file mode 100644 index 0000000000..4cbf24c11e --- /dev/null +++ b/tests/integration/test_audit_current_intent_contract.py @@ -0,0 +1,388 @@ +"""Current-intent CLI controls awaiting the coordinated v0.2 requirement binding.""" + +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import sqlite3 +from collections.abc import Iterator +from contextlib import closing +from dataclasses import dataclass, replace +from pathlib import Path +from unittest.mock import Mock + +import pytest +import requests +from click.testing import CliRunner, Result + +from apm_cli import config +from apm_cli.cli import cli +from apm_cli.utils import console +from apm_cli.utils.yaml_io import dump_yaml, load_yaml +from tests.utils.artifact_snapshot import ArtifactSnapshot, assert_unchanged +from tests.utils.isolated_apm_environment import IsolatedApmEnvironment +from tests.utils.local_package import LocalPackageFactory + +pytestmark = pytest.mark.component + +_DEPLOYED_SKILL = ".grok/skills/intent/SKILL.md" + + +@dataclass(frozen=True) +class _AuditProject: + project: Path + home: Path + source: Path + + def command(self, *arguments: str) -> Result: + """Invoke the real command stack in process, with no binary prerequisite.""" + return CliRunner().invoke(cli, list(arguments), catch_exceptions=False) + + def audit_result(self, expected_exit: int) -> Result: + """Keep all live manifest, lockfile, configuration and target bytes intact.""" + project_before = ArtifactSnapshot.capture(self.project) + home_before = ArtifactSnapshot.capture(self.home) + result = self.command("audit", "--ci", "--no-policy", "--no-fail-fast", "-f", "json") + assert result.exit_code == expected_exit, result.output + assert_unchanged(project_before, ArtifactSnapshot.capture(self.project)) + assert_unchanged(home_before, ArtifactSnapshot.capture(self.home)) + return result + + def audit(self, expected_exit: int) -> dict: + """Return structured checks from a completed audit.""" + result = self.audit_result(expected_exit) + return {row["name"]: row for row in json.loads(result.stdout)["checks"]} + + +@pytest.fixture +def installed(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[_AuditProject]: + """Materialize source, saved intent and ownership through actual commands.""" + isolated = IsolatedApmEnvironment.create(tmp_path / "audit", base_env=dict(os.environ)) + environment = isolated.subprocess_env() + for key in os.environ.keys() - environment.keys(): + monkeypatch.delenv(key) + for key, value in environment.items(): + monkeypatch.setenv(key, value) + monkeypatch.setattr(config, "CONFIG_DIR", str(isolated.config_root)) + monkeypatch.setattr(config, "CONFIG_FILE", str(isolated.config_root / "config.json")) + monkeypatch.setattr(config, "_config_cache", None) + monkeypatch.setattr(console, "_console_instance", None) + monkeypatch.setattr(console, "_console_stderr", False) + + def no_network(*args: object, **kwargs: object) -> None: + raise AssertionError("Conformance audit must not issue HTTP requests") + + monkeypatch.setattr(requests.Session, "request", no_network) + factory = LocalPackageFactory(isolated.package_root) + package = factory.create("source") + source = factory.add_skill( + package, "intent", "---\nname: intent\ndescription: Source intent\n---\n# Expected bytes\n" + ) + project = ( + LocalPackageFactory(isolated.work_root) + .create("consumer", dependencies=[{"path": str(package.root)}]) + .root + ) + sentinel = project / ".github/workflows/unrelated.yml" + sentinel.parent.mkdir(parents=True) + sentinel.write_text("name: unrelated\n", encoding="utf-8") + monkeypatch.chdir(project) + case = _AuditProject(project, isolated.home, source) + for arguments in ( + ("experimental", "enable", "grok-cloud"), + ("config", "set", "target", "grok-cloud"), + ("install", "--no-policy", "--parallel-downloads", "0"), + ): + result = case.command(*arguments) + assert result.exit_code == 0, result.output + assert (project / _DEPLOYED_SKILL).read_bytes() == source.read_bytes() + yield case + + +@pytest.mark.parametrize("cold", [False, True], ids=["warm", "cold"]) +def test_saved_target_drives_read_only_source_replay(installed: _AuditProject, cold: bool) -> None: + """An unrelated detection signal must not replace the saved current target.""" + if cold: + shutil.rmtree(installed.project / "apm_modules") + checks = installed.audit(0) + assert all(row["passed"] for row in checks.values()) + assert checks["drift"]["passed"] is True + assert not (installed.project / ".agents/skills/intent").exists() + + +def test_user_scope_replay_keeps_user_target_bytes( + installed: _AuditProject, monkeypatch: pytest.MonkeyPatch +) -> None: + """The user manifest and HOME deployment root remain distinct during replay.""" + result = installed.command( + "install", + "--global", + str(installed.source.parents[2]), + "--no-policy", + "--parallel-downloads", + "0", + ) + assert result.exit_code == 0, result.output + assert (installed.home / _DEPLOYED_SKILL).read_bytes() == installed.source.read_bytes() + user = replace(installed, project=installed.home / ".apm") + monkeypatch.chdir(user.project) + assert all(check["passed"] for check in user.audit(0).values()) + + +def test_full_audit_does_not_recreate_absent_configuration( + installed: _AuditProject, monkeypatch: pytest.MonkeyPatch +) -> None: + """Scanner discovery must share the replay adapter's read-only configuration.""" + manifest_path = installed.project / "apm.yml" + manifest = load_yaml(manifest_path) + manifest["targets"] = ["grok-build"] + dump_yaml(manifest, manifest_path) + config_path = installed.home / ".apm/config.json" + config_path.unlink() + monkeypatch.setattr(config, "_config_cache", None) + assert all(check["passed"] for check in installed.audit(0).values()) + assert not config_path.exists() + + +@pytest.mark.parametrize("invalid", ["claudee", [], 42, None]) +@pytest.mark.parametrize("manifest_wins", [False, True]) +def test_malformed_saved_intent_fails_only_when_selected( + installed: _AuditProject, invalid: object, manifest_wins: bool +) -> None: + """Absent and invalid saved targets differ, but manifest precedence remains.""" + config.update_config({"install_target": invalid}) + if manifest_wins: + path = installed.project / "apm.yml" + manifest = load_yaml(path) + manifest["targets"] = ["grok-build"] + dump_yaml(manifest, path) + assert all(check["passed"] for check in installed.audit(0).values()) + else: + checks = installed.audit(1) + assert checks["target-resolution"]["passed"] is False + + +def test_native_workflow_replay_fails_before_writer( + installed: _AuditProject, monkeypatch: pytest.MonkeyPatch +) -> None: + """An available native DB is not a scratch backend; no native writer may run.""" + from apm_cli.integration.copilot_app_workflow_integrator import CopilotAppWorkflowIntegrator + + runtime = installed.home / "stand-in-app" + runtime.mkdir() + database = runtime / "data.db" + with closing(sqlite3.connect(database)) as connection: + connection.executescript( + "CREATE TABLE workflows(id TEXT PRIMARY KEY, prompt TEXT, enabled INTEGER);" + "INSERT INTO workflows VALUES('sentinel', 'unchanged', 1);" + "PRAGMA user_version=13;" + ) + for suffix in ("-wal", "-shm"): + Path(f"{database}{suffix}").write_bytes(b"sidecar sentinel") + monkeypatch.setenv("APM_COPILOT_APP_DB", str(database)) + native_writer = Mock(side_effect=AssertionError("Native workflow writer reached by audit")) + monkeypatch.setattr(CopilotAppWorkflowIntegrator, "integrate", native_writer) + prompt = installed.source.parents[2] / ".apm/prompts/daily.prompt.md" + prompt.parent.mkdir(parents=True) + prompt.write_text("---\nname: daily\ninterval: daily\n---\nDo not deploy during audit.\n") + assert installed.command("experimental", "enable", "copilot-app").exit_code == 0 + assert installed.command("config", "set", "target", "copilot-app").exit_code == 0 + checks = installed.audit(1) + native_writer.assert_not_called() + assert "scratch replay" in checks["drift"]["message"] + + +@pytest.mark.parametrize( + "mutation", + [ + "clean", + "missing-claim", + "contracted", + "legacy-directory", + "legacy-contracted", + "forged-content", + "unavailable-root", + "unavailable-root-no-config", + "symlink", + ], +) +def test_native_user_skill_replay_preserves_layout_and_comparison( + installed: _AuditProject, monkeypatch: pytest.MonkeyPatch, mutation: str +) -> None: + """Audit a controlled installed snapshot; Cowork install itself is not exercised.""" + native_root = installed.home / "stand-in-cowork-skills" + native_root.mkdir() + neighbor = native_root / "unrelated.txt" + neighbor.write_bytes(b"untouched neighbor") + monkeypatch.setenv("APM_COPILOT_COWORK_SKILLS_DIR", str(native_root)) + assert installed.command("experimental", "enable", "copilot-cowork").exit_code == 0 + assert installed.command("config", "set", "target", "copilot-cowork").exit_code == 0 + native_source = installed.source.parents[2] / "SKILL.md" + shutil.copy2(installed.source, native_source) + installed.source.unlink() + shutil.copytree(native_source.parent, native_root / "source") + user = replace(installed, project=installed.home / ".apm") + shutil.copy2(installed.project / "apm.yml", user.project / "apm.yml") + shutil.copytree(installed.project / "apm_modules", user.project / "apm_modules", symlinks=True) + document = load_yaml(installed.project / "apm.lock.yaml") + for deployment in document["deployments"]: + deployment.update( + kind="uri", + target="copilot-cowork", + scope="user", + value=deployment["value"].replace(".grok/skills/intent", "cowork://skills/source"), + ) + for dependency in document["dependencies"]: + dependency["deployed_files"] = [ + value.replace(".grok/skills/intent", "cowork://skills/source") + for value in dependency["deployed_files"] + ] + dependency["deployed_file_hashes"] = { + value.replace(".grok/skills/intent", "cowork://skills/source"): digest + for value, digest in dependency["deployed_file_hashes"].items() + } + dump_yaml(document, user.project / "apm.lock.yaml") + assert (native_root / "source/SKILL.md").read_bytes() == native_source.read_bytes() + monkeypatch.chdir(user.project) + if mutation in {"missing-claim", "legacy-directory", "legacy-contracted", "forged-content"}: + path = user.project / "apm.lock.yaml" + document = load_yaml(path) + if mutation == "forged-content": + contents = b"Locally forged bytes\n" + (native_root / "source/SKILL.md").write_bytes(contents) + digest = "sha256:" + hashlib.sha256(contents).hexdigest() + for deployment in document["deployments"]: + if deployment["value"] == "cowork://skills/source/SKILL.md": + deployment["content_hash"] = digest + for dependency in document["dependencies"]: + dependency["deployed_file_hashes"]["cowork://skills/source/SKILL.md"] = digest + else: + document["deployments"] = [] + if mutation != "missing-claim": + document.pop("deployments") + for dependency in document["dependencies"]: + dependency["deployed_files"] = ( + [] if mutation == "missing-claim" else ["cowork://skills/source"] + ) + dependency["deployed_file_hashes"] = {} + dump_yaml(document, path) + if mutation in { + "contracted", + "legacy-contracted", + "unavailable-root", + "unavailable-root-no-config", + }: + assert installed.command("config", "set", "target", "claude").exit_code == 0 + if mutation.startswith("unavailable-root"): + native_root.rename(native_root.with_name("unavailable-cowork-skills")) + monkeypatch.delenv("APM_COPILOT_COWORK_SKILLS_DIR") + if mutation == "unavailable-root-no-config": + manifest_path = user.project / "apm.yml" + manifest = load_yaml(manifest_path) + manifest["targets"] = ["claude"] + dump_yaml(manifest, manifest_path) + Path(config.CONFIG_FILE).unlink() + config._invalidate_config_cache() + elif mutation == "symlink": + moved = native_root / "source" + external = installed.home / "outside-native-root" + moved.rename(external) + moved.symlink_to(external, target_is_directory=True) + checks = user.audit(0 if mutation in {"clean", "legacy-directory"} else 1) + if mutation in {"clean", "legacy-directory"}: + assert all(check["passed"] for check in checks.values()) + elif mutation.startswith("unavailable-root"): + assert "deployment root is unavailable" in checks["drift"]["message"] + else: + kind = { + "missing-claim": "unrecorded", + "forged-content": "modified", + "symlink": "unintegrated", + }.get(mutation, "orphaned") + assert any( + detail.startswith(f"{kind}: ") and detail.endswith("source/SKILL.md") + for detail in checks["drift"]["details"] + ) + if not mutation.startswith("unavailable-root"): + assert neighbor.read_bytes() == b"untouched neighbor" + + +@pytest.mark.parametrize("intent", ["manifest", "config", "detection"]) +def test_current_intent_overrides_old_target_ownership( + installed: _AuditProject, intent: str +) -> None: + """Contracted targets stay comparable; ownership cannot authorize replay.""" + expected = ".claude/skills/intent/SKILL.md" + if intent == "manifest": + path = installed.project / "apm.yml" + document = load_yaml(path) + document["targets"] = ["claude"] + dump_yaml(document, path) + elif intent == "config": + assert installed.command("config", "set", "target", "claude").exit_code == 0 + else: + assert installed.command("config", "unset", "target").exit_code == 0 + expected = ".agents/skills/intent/SKILL.md" + checks = installed.audit(1) + assert f"unintegrated: {expected}" in checks["drift"]["details"] + if intent != "detection": + assert f"orphaned: {_DEPLOYED_SKILL}" in checks["drift"]["details"] + else: + # Existing .grok detection also selects Grok Build, which shares this root. + assert (installed.project / _DEPLOYED_SKILL).read_bytes() == installed.source.read_bytes() + + +@pytest.mark.parametrize("invalid", ["disabled", "manifest"]) +def test_invalid_current_selection_cannot_pass_empty_replay( + installed: _AuditProject, invalid: str +) -> None: + """A malformed manifest or unavailable selected target is not absent intent.""" + if invalid == "disabled": + assert installed.command("experimental", "disable", "grok-cloud").exit_code == 0 + checks = installed.audit(1) + assert checks["target-resolution"]["passed"] is False + else: + path = installed.project / "apm.yml" + document = load_yaml(path) + document["targets"] = ["grok-cloud"] + dump_yaml(document, path) + result = installed.audit_result(2) + assert "Unknown target 'grok-cloud'" in result.output + + +@pytest.mark.parametrize("mutation", ["content", "missing-claim"]) +def test_configured_target_keeps_integrity_and_membership_checks( + installed: _AuditProject, mutation: str +) -> None: + """Re-verify configured-target hashes and detect omitted deployment records.""" + if mutation == "content": + (installed.project / _DEPLOYED_SKILL).write_bytes(b"Unexpected deployed bytes\n") + else: + path = installed.project / "apm.lock.yaml" + document = load_yaml(path) + document["deployments"] = [] + for dependency in document["dependencies"]: + dependency["deployed_files"] = [] + dependency["deployed_file_hashes"] = {} + dump_yaml(document, path) + checks = installed.audit(1) + if mutation == "content": + assert checks["content-integrity"]["passed"] is False + kind = "modified" + else: + kind = "unrecorded" + assert f"{kind}: {_DEPLOYED_SKILL}" in checks["drift"]["details"] + + +def test_configured_target_does_not_authorize_invalid_owners(installed: _AuditProject) -> None: + """Current target intent cannot bless an owner missing from the dependency set.""" + path = installed.project / "apm.lock.yaml" + document = load_yaml(path) + for deployment in document["deployments"]: + deployment["owners"] = ["removed/owner"] + deployment["active_owner"] = "removed/owner" + dump_yaml(document, path) + checks = installed.audit(1) + assert checks["deployment-ledger-owners"]["passed"] is False diff --git a/tests/unit/install/test_audit_target_roots.py b/tests/unit/install/test_audit_target_roots.py index 88d11dc114..4d55c58d11 100644 --- a/tests/unit/install/test_audit_target_roots.py +++ b/tests/unit/install/test_audit_target_roots.py @@ -25,7 +25,7 @@ def test_configured_experimental_target_is_read_only(tmp_path: Path, user_scope: profiles = resolve_audit_targets(project, user_scope=user_scope) assert tuple(profile.name for profile in profiles) == ("grok-cloud",) assert profiles[0].root_dir == ".grok" - config.assert_called_once_with(create_config=False) + config.assert_called_once_with(create_config=False, strict=True) enabled.assert_called_once_with("grok_cloud", create_config=False) assert_unchanged(before, ArtifactSnapshot.capture(tmp_path)) @@ -89,6 +89,8 @@ def test_missing_config_is_not_created(tmp_path: Path, monkeypatch: pytest.Monke home = tmp_path / "home" home.mkdir() monkeypatch.setattr(Path, "home", lambda: home) + monkeypatch.setattr(config, "CONFIG_DIR", str(home / ".apm")) + monkeypatch.setattr(config, "CONFIG_FILE", str(home / ".apm/config.json")) monkeypatch.setattr(config, "_config_cache", None) before = ArtifactSnapshot.capture(home) assert resolve_audit_targets(tmp_path)[0].name == "copilot" diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 028061cdcb..8cdebd254e 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -92,6 +92,19 @@ class TestInstallTargetConfig: def test_default_is_none(self, isolated_config): assert config_mod.get_install_target() is None + @pytest.mark.parametrize("value", ["claudee", [], 42, None]) + def test_strict_read_rejects_present_invalid_target(self, isolated_config, value): + config_mod.update_config({"install_target": value}) + before = isolated_config.read_bytes() + assert config_mod.get_install_target(create_config=False) is None + with pytest.raises(ValueError, match="Invalid saved target configuration"): + config_mod.get_install_target(create_config=False, strict=True) + assert isolated_config.read_bytes() == before + + def test_strict_absent_read_does_not_create_config(self, isolated_config): + assert config_mod.get_install_target(create_config=False, strict=True) is None + assert not isolated_config.exists() + def test_set_and_get_roundtrip(self, isolated_config): config_mod.set_install_target("claude") assert config_mod.get_install_target() == "claude" From 68015fa39cfb6a1379d7542cff8cb9776055afaa Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sun, 6 Sep 2026 13:43:45 +0200 Subject: [PATCH 10/34] spec: complete corrective v0.2.0 review candidate Integrate the audit owner's exact current-intent and read-only replay contract as req-lk-023. Move all 30 original CLI controls without changing their executable AST; bind the 29 replay cases to the new requirement and the invalid-owner control to req-pl-016. Preserve req-lk-017's inherited obligation and disclose the reference CLI's bare-audit baseline gap instead of silently weakening it. Retain the prior minor indefinitely, reuse all wire schemas, keep reserved features inactive, and generate a 122-entry exact-revision static binding inventory. This is an immutable review candidate, not publication or ratification; latest remains unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 6 +- CONFORMANCE.json | 55 ++++++++++-- CONFORMANCE.md | 13 ++- CONTRIBUTING.md | 14 +-- .../manifests/openapm-v0.2.requirements.yml | 7 +- docs/src/content/docs/specs/conformance.md | 2 + docs/src/content/docs/specs/openapm-v0.2.md | 88 ++++++++++++++++--- tests/spec_conformance/gen_statement.py | 18 +++- .../test_audit_current_intent_contract.py | 12 ++- .../test_spec_version_contract.py | 21 +++++ 10 files changed, 206 insertions(+), 30 deletions(-) rename tests/{integration => spec_conformance}/test_audit_current_intent_contract.py (97%) diff --git a/CHANGELOG.md b/CHANGELOG.md index f768b28fc6..1124697836 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Security - -- Keep remote dependencies with local-looking repository names behind remote source containment; only established local acquisition context authorizes transitive local reads. (#2820) - ### Fixed -- Global installs retain local relative children using their declaring source; `docs/src/content/docs/specs/openapm-v0.1.md` reconciles trusted-local anchoring with remote and internal-symlink containment, backed by executable conformance cases. (#2820) +- Global installs anchor local children to established declaring sources without treating disguised remote names as local, and audit replays current target intent without modifying live configuration or native state. The corrective draft `docs/src/content/docs/specs/openapm-v0.2.md` versions these contracts separately; migration: pin assessments to exact `v0.2.0`, while the previous minor remains available indefinitely. Publication and ratification are pending. (#2820) ## [0.29.1] - 2026-09-06 diff --git a/CONFORMANCE.json b/CONFORMANCE.json index cd1b878390..e68adb398e 100644 --- a/CONFORMANCE.json +++ b/CONFORMANCE.json @@ -1,4 +1,9 @@ { + "assessment_limitations": [ + "The reference CLI's bare content audit uses source-derived drift replay, not the stored-hash baseline required by req-lk-017's unqualified audit obligation. The stored-hash and full-SHA consistency baselines are exercised in CI/conformance audit. This inventory does not claim full Consumer conformance in bare audit mode.", + "The native Cowork audit controls use a controlled pre-existing standalone-skill snapshot; they do not establish a successful Cowork install/audit round trip. The Grok user-scope control does exercise install and audit.", + "A selected native runtime without an isolated replay backend is reported as unsupported before its live writer. No new native database scratch backend or hosted-runtime evidence is supplied by this inventory." + ], "consumer_user_scope": { "lockfile_location": "~/.apm/apm.lock.yaml", "manifest_location": "~/.apm/apm.yml", @@ -6,7 +11,7 @@ }, "generator": "gen_statement.py v2", "inventory_kind": "static-test-bindings", - "manifest_sha256": "1b9d032b871b4b59c93b5e2548b80dc53a002cbe21c6f4dbb83dec3cdf128cfb", + "manifest_sha256": "c0903dc53f2dcadfa61716d376f32383796ba21a57fd3e199548c674961f3052", "requirements": [ { "conformance_class": "consumer", @@ -314,6 +319,45 @@ "tests/spec_conformance/test_lockfile_reqs.py::test_materialization_spelling_migrates_one_case_variant_transactionally" ] }, + { + "conformance_class": "consumer", + "id": "req-lk-023", + "keyword": "MUST", + "section": "5.5", + "status": "active", + "test_count": 29, + "tests": [ + "tests/spec_conformance/test_audit_current_intent_contract.py::test_configured_target_keeps_integrity_and_membership_checks[content]", + "tests/spec_conformance/test_audit_current_intent_contract.py::test_configured_target_keeps_integrity_and_membership_checks[missing-claim]", + "tests/spec_conformance/test_audit_current_intent_contract.py::test_current_intent_overrides_old_target_ownership[config]", + "tests/spec_conformance/test_audit_current_intent_contract.py::test_current_intent_overrides_old_target_ownership[detection]", + "tests/spec_conformance/test_audit_current_intent_contract.py::test_current_intent_overrides_old_target_ownership[manifest]", + "tests/spec_conformance/test_audit_current_intent_contract.py::test_full_audit_does_not_recreate_absent_configuration", + "tests/spec_conformance/test_audit_current_intent_contract.py::test_invalid_current_selection_cannot_pass_empty_replay[disabled]", + "tests/spec_conformance/test_audit_current_intent_contract.py::test_invalid_current_selection_cannot_pass_empty_replay[manifest]", + "tests/spec_conformance/test_audit_current_intent_contract.py::test_malformed_saved_intent_fails_only_when_selected[False-42]", + "tests/spec_conformance/test_audit_current_intent_contract.py::test_malformed_saved_intent_fails_only_when_selected[False-None]", + "tests/spec_conformance/test_audit_current_intent_contract.py::test_malformed_saved_intent_fails_only_when_selected[False-claudee]", + "tests/spec_conformance/test_audit_current_intent_contract.py::test_malformed_saved_intent_fails_only_when_selected[False-invalid1]", + "tests/spec_conformance/test_audit_current_intent_contract.py::test_malformed_saved_intent_fails_only_when_selected[True-42]", + "tests/spec_conformance/test_audit_current_intent_contract.py::test_malformed_saved_intent_fails_only_when_selected[True-None]", + "tests/spec_conformance/test_audit_current_intent_contract.py::test_malformed_saved_intent_fails_only_when_selected[True-claudee]", + "tests/spec_conformance/test_audit_current_intent_contract.py::test_malformed_saved_intent_fails_only_when_selected[True-invalid1]", + "tests/spec_conformance/test_audit_current_intent_contract.py::test_native_user_skill_replay_preserves_layout_and_comparison[clean]", + "tests/spec_conformance/test_audit_current_intent_contract.py::test_native_user_skill_replay_preserves_layout_and_comparison[contracted]", + "tests/spec_conformance/test_audit_current_intent_contract.py::test_native_user_skill_replay_preserves_layout_and_comparison[forged-content]", + "tests/spec_conformance/test_audit_current_intent_contract.py::test_native_user_skill_replay_preserves_layout_and_comparison[legacy-contracted]", + "tests/spec_conformance/test_audit_current_intent_contract.py::test_native_user_skill_replay_preserves_layout_and_comparison[legacy-directory]", + "tests/spec_conformance/test_audit_current_intent_contract.py::test_native_user_skill_replay_preserves_layout_and_comparison[missing-claim]", + "tests/spec_conformance/test_audit_current_intent_contract.py::test_native_user_skill_replay_preserves_layout_and_comparison[symlink]", + "tests/spec_conformance/test_audit_current_intent_contract.py::test_native_user_skill_replay_preserves_layout_and_comparison[unavailable-root-no-config]", + "tests/spec_conformance/test_audit_current_intent_contract.py::test_native_user_skill_replay_preserves_layout_and_comparison[unavailable-root]", + "tests/spec_conformance/test_audit_current_intent_contract.py::test_native_workflow_replay_fails_before_writer", + "tests/spec_conformance/test_audit_current_intent_contract.py::test_saved_target_drives_read_only_source_replay[cold]", + "tests/spec_conformance/test_audit_current_intent_contract.py::test_saved_target_drives_read_only_source_replay[warm]", + "tests/spec_conformance/test_audit_current_intent_contract.py::test_user_scope_replay_keeps_user_target_bytes" + ] + }, { "conformance_class": "producer", "id": "req-mf-001", @@ -797,8 +841,9 @@ "keyword": "MUST", "section": "6.8", "status": "active", - "test_count": 1, + "test_count": 2, "tests": [ + "tests/spec_conformance/test_audit_current_intent_contract.py::test_configured_target_does_not_authorize_invalid_owners", "tests/spec_conformance/test_policy_reqs.py::test_deployment_ledger_owner_is_hard_integrity_failure" ] }, @@ -1456,11 +1501,11 @@ ], "spec_citation": "https://microsoft.github.io/apm/spec/v0.2.0", "spec_path": "docs/src/content/docs/specs/openapm-v0.2.md", - "spec_sha256": "2b673cd6404635d8ec8ee783a60abd6ad4ad94d0e257e2b59c2c3600c743d99e", + "spec_sha256": "2a0dd8bb0a807d02f18cd19ba2c5316ff82467205aae906e352478a13b868cd6", "spec_version": "v0.2.0", "summary_by_class": { "consumer": { - "active": 91, + "active": 92, "skipped": 0, "unbound": 0, "xfail": 0 @@ -1484,5 +1529,5 @@ "xfail": 0 } }, - "total_requirements": 121 + "total_requirements": 122 } diff --git a/CONFORMANCE.md b/CONFORMANCE.md index 8c7f4b1f25..e21e5d6d76 100644 --- a/CONFORMANCE.md +++ b/CONFORMANCE.md @@ -17,6 +17,14 @@ This inventory assesses only the selected corrective revision. It does not estab The four conformance classes (Producer, Consumer, Registry, Governance) are inventoried below, not certified by this report. The Registry binding includes the trust-anchor invariant test in `tests/spec_conformance/test_registry_reqs.py`, which hashes the committed Registry-archive fixture and asserts equality with the digest the paired lockfile advertises (sec.11.3.3, req-rg-001). +## Assessment limitations + +The reference CLI's bare content audit uses source-derived drift replay, not the stored-hash baseline required by req-lk-017's unqualified audit obligation. The stored-hash and full-SHA consistency baselines are exercised in CI/conformance audit. This inventory does not claim full Consumer conformance in bare audit mode. + +The native Cowork audit controls use a controlled pre-existing standalone-skill snapshot; they do not establish a successful Cowork install/audit round trip. The Grok user-scope control does exercise install and audit. + +A selected native runtime without an isolated replay backend is reported as unsupported before its live writer. No new native database scratch backend or hosted-runtime evidence is supplied by this inventory. + ## Consumer user-scope disclosure - Manifest: `~/.apm/apm.yml` @@ -28,7 +36,7 @@ The four conformance classes (Producer, Consumer, Registry, Governance) are inve | Class | Active | Skipped | Xfail | Unbound | |-------|-------:|--------:|------:|--------:| | Producer | 12 | 0 | 0 | 0 | -| Consumer | 91 | 0 | 0 | 0 | +| Consumer | 92 | 0 | 0 | 0 | | Registry | 1 | 0 | 0 | 0 | | Governance | 17 | 0 | 0 | 0 | @@ -62,6 +70,7 @@ The four conformance classes (Producer, Consumer, Registry, Governance) are inve | [req-lk-020](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-020) | MUST | 5.2 | consumer | active | 3 | - | | [req-lk-021](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-021) | MUST | 5.2 | consumer | active | 2 | - | | [req-lk-022](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-022) | MUST | 5.2 | consumer | active | 4 | - | +| [req-lk-023](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-023) | MUST | 5.5 | consumer | active | 29 | - | | [req-mf-001](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-001) | MUST | 4.1 | producer | active | 1 | - | | [req-mf-002](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-002) | MUST | 4.1 | producer | active | 1 | - | | [req-mf-003](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-003) | MUST | 4.1 | producer | active | 1 | - | @@ -101,7 +110,7 @@ The four conformance classes (Producer, Consumer, Registry, Governance) are inve | [req-pl-013](docs/src/content/docs/specs/openapm-v0.2.md#req-pl-013) | MUST | 6.8 | governance | active | 1 | - | | [req-pl-014](docs/src/content/docs/specs/openapm-v0.2.md#req-pl-014) | MUST | 6.8 | governance | active | 1 | - | | [req-pl-015](docs/src/content/docs/specs/openapm-v0.2.md#req-pl-015) | MUST | 6.3.5 | governance | active | 1 | - | -| [req-pl-016](docs/src/content/docs/specs/openapm-v0.2.md#req-pl-016) | MUST | 6.8 | governance | active | 1 | - | +| [req-pl-016](docs/src/content/docs/specs/openapm-v0.2.md#req-pl-016) | MUST | 6.8 | governance | active | 2 | - | | [req-pl-017](docs/src/content/docs/specs/openapm-v0.2.md#req-pl-017) | MUST | 6.8 | governance | active | 1 | - | | [req-pr-001](docs/src/content/docs/specs/openapm-v0.2.md#req-pr-001) | MUST | 8.2 | consumer | active | 1 | - | | [req-pr-002](docs/src/content/docs/specs/openapm-v0.2.md#req-pr-002) | MUST | 8.3 | consumer | active | 1 | - | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 21d4f63556..bbfdb9f511 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -423,9 +423,10 @@ Avoid these anti-patterns: - Do not read `is_enabled()` at module import time. - Do not persist flag state anywhere other than `~/.apm/config.json` via `update_config`. -## Adding or changing a normative requirement (OpenAPM v0.1) +## Adding or changing a normative requirement (OpenAPM) -The OpenAPM v0.1 spec (`docs/src/content/docs/specs/openapm-v0.1.md`) +The selected OpenAPM spec (`tests/spec_conformance/_manifest.py` owns +the active artifact and exact revision) and APM the implementation are co-evolved in this repo. APM is the sole implementation of the spec. To prevent the spec from rotting into a document of lies, every normative change MUST land as three @@ -438,8 +439,8 @@ Three-step ritual: 1. **Spec edit.** Add or change a `` anchor with prose in the spec body. Add or change the matching Appendix C row. 2. **Manifest edit.** Add or change the entry in - `docs/src/content/docs/specs/manifests/openapm-v0.1.requirements.yml` - so it stays a byte-equivalent projection of the canonical anchors. + selected informative manifest under `docs/public/specs/manifests/` + so its IDs, keywords, sections, and classes match the canonical anchors. 3. **Test edit.** Add or extend a `@pytest.mark.req("req-XXX")` test under `tests/spec_conformance/`. If a real assertion is not yet possible, call `waive("...")` from `_helpers.py` with a one-line @@ -453,7 +454,10 @@ uv run --extra dev python -m tests.spec_conformance.gen_statement ``` and commit the resulting `CONFORMANCE.{md,json}` at repo root. CI -gates a clean diff. +gates a clean diff. Generation collects the full selected suite afresh and +records static bindings, not runtime pass results. Run the suite separately +and retain execution evidence. The retained previous minor is a preservation +artifact, not an alternative edit target for the active assessment. Common modes the ritual catches: diff --git a/docs/public/specs/manifests/openapm-v0.2.requirements.yml b/docs/public/specs/manifests/openapm-v0.2.requirements.yml index 5b3e7b48df..c4b815b275 100644 --- a/docs/public/specs/manifests/openapm-v0.2.requirements.yml +++ b/docs/public/specs/manifests/openapm-v0.2.requirements.yml @@ -19,7 +19,7 @@ # # Informative 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. Audit-owner clause still pending. +# format, not the assessed spec version. Publication and ratification are pending. requirements_format_version: "1" spec_version: "v0.2.0" @@ -225,6 +225,11 @@ requirements: 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. test_audit_current_intent_contract.py carries 29 genuine replay/current-intent cases; its separate invalid-owner case binds req-pl-016." - id: req-pl-001 keyword: MUST section: "6.1" diff --git a/docs/src/content/docs/specs/conformance.md b/docs/src/content/docs/specs/conformance.md index a5288b1607..02f78ccd0d 100644 --- a/docs/src/content/docs/specs/conformance.md +++ b/docs/src/content/docs/specs/conformance.md @@ -36,4 +36,6 @@ An `active` binding does not establish that a test ran or passed. Some bindings Current local-source cases assess the corrective req-mf-016 only. They do not prove that the CLI ever satisfied the previous minor's blanket project-root refusal. Preserving that artifact does not claim that the current suite proves historical compliance. The requirements manifest remains informative, existing wire-schema identities are unchanged, and the `latest` citation is not advanced during draft preparation. +The audit cases bind current target intent and read-only replay to req-lk-023. They do not erase inherited integrity requirements: the CLI's bare content audit uses source-derived drift, while its stored-hash and full-SHA consistency baselines run in CI/conformance audit. The unqualified audit obligation in req-lk-017 remains an explicit bare-mode conformance limitation. Native Cowork cases use pre-existing fixture state, not a successful native install round trip. + For the broader drift-detection story (how the spec authors prevent the spec from rotting relative to the only implementation), see [`CONTRIBUTING.md` -- Spec amendment workflow](https://github.com/microsoft/apm/blob/main/CONTRIBUTING.md#spec-amendment-workflow). diff --git a/docs/src/content/docs/specs/openapm-v0.2.md b/docs/src/content/docs/specs/openapm-v0.2.md index fad2e54082..047b0a3274 100644 --- a/docs/src/content/docs/specs/openapm-v0.2.md +++ b/docs/src/content/docs/specs/openapm-v0.2.md @@ -17,8 +17,8 @@ remain pending. Citing this draft as a ratified specification is inappropriate. This bounded corrective minor changes only local-source provenance and anchoring ([req-mf-016](#req-mf-016)) and the current-intent, read-only -audit contract in Section 5.5. The audit-owner wording is pending; this -draft is not a publishable candidate until that input is integrated. +audit contract ([req-lk-023](#req-lk-023)) in Section 5.5. This is a +review candidate, not a publication or ratification record. Features previously reserved for v0.2 remain reserved for a future revision; they are not activated by v0.2.0. @@ -145,7 +145,7 @@ between the companion corpus and the implementation. ### 1.3 Document conventions -- This draft carries **121 normative statements** indexed in +- This revision carries **122 normative statements** indexed in [Appendix C](#appendix-c-index-of-normative-statements). - All on-disk files defined by this specification are **YAML 1.2** parsed under the safe subset defined in @@ -1223,8 +1223,58 @@ regenerating the lockfile from the manifest. ### 5.5 Drift and integrity model -The lockfile is the contract `apm audit` validates the workspace -against. +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; directory claims retain their existing +membership semantics and containment boundaries. 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. + +**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 @@ -1385,7 +1435,8 @@ This section's normative statements are: [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-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). @@ -1717,8 +1768,13 @@ 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`, detected drift -MUST be reported without, by itself, altering the audit exit status. +`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 @@ -2421,6 +2477,10 @@ 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 @@ -3436,6 +3496,7 @@ conformance statement identifying: [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), @@ -3870,6 +3931,7 @@ the existing conformance classes or disabling [req-rg-001](#req-rg-001). | [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 | @@ -3944,7 +4006,7 @@ the existing conformance classes or disabling [req-rg-001](#req-rg-001). | [req-cf-001](#req-cf-001) | MUST | 12.5 | consumer | | [req-cf-002](#req-cf-002) | MUST | 12.3 | consumer | -**Total normative statements: 121** (116 MUST, 5 SHOULD). +**Total normative statements: 122** (117 MUST, 5 SHOULD). The [req-mf-016](#req-mf-016) consumer entry covers source anchoring, user-scope admission, remote-repository containment, and local @@ -3966,7 +4028,13 @@ Only those two contracts change behavior. The approved local-source clauses replace [req-mf-016](#req-mf-016)'s previous blanket project-root escape rejection with source-provenance admission, original declaring-source anchors, remote-repository containment, and internal local-symlink rules. -The audit clause is awaiting its owner's final wording and evidence. +[req-lk-023](#req-lk-023) adds current-intent precedence, source-derived +expected output, comparison of prior target claims, and read-only replay +with unsupported native writers refused before invocation. Section 8.4 +cross-references that selection order; [req-pl-014](#req-pl-014) +distinguishes default-mode advisory drift from CI/conformance failures. +Statement count: **121 -> 122** (117 MUST, 5 SHOULD). No other +requirement identifier is added, removed, or renumbered. **Classification and preservation.** These are substantive changes to the conformance contract under Sections 9.1, 9.2, and 9.4, not same-minor diff --git a/tests/spec_conformance/gen_statement.py b/tests/spec_conformance/gen_statement.py index 88f9a436bf..c97e37cd3d 100644 --- a/tests/spec_conformance/gen_statement.py +++ b/tests/spec_conformance/gen_statement.py @@ -41,6 +41,18 @@ "MCPClientAdapter.supports_user_scope (OpenAPM Target Registry v0.1 implementation profile)" ), } +ASSESSMENT_LIMITATIONS = [ + "The reference CLI's bare content audit uses source-derived drift replay, not the " + "stored-hash baseline required by req-lk-017's unqualified audit obligation. " + "The stored-hash and full-SHA consistency baselines are exercised in CI/conformance " + "audit. This inventory does not claim full Consumer conformance in bare audit mode.", + "The native Cowork audit controls use a controlled pre-existing standalone-skill " + "snapshot; they do not establish a successful Cowork install/audit round trip. " + "The Grok user-scope control does exercise install and audit.", + "A selected native runtime without an isolated replay backend is reported as " + "unsupported before its live writer. No new native database scratch backend " + "or hosted-runtime evidence is supplied by this inventory.", +] def _ensure_coverage() -> Coverage: @@ -141,6 +153,7 @@ def build_json() -> dict: "total_requirements": len(entries), "summary_by_class": summary, "consumer_user_scope": USER_SCOPE_DISCLOSURE, + "assessment_limitations": ASSESSMENT_LIMITATIONS, "requirements": entries, } @@ -218,7 +231,10 @@ def build_md(doc: dict) -> str: waivers_section.append(f"- {w}") waivers_section.append("") waivers_md = "\n".join(waivers_section) + "\n" - return preamble + scope_section + summary_section + table + waivers_md + limitations = ( + "## Assessment limitations\n\n" + "\n\n".join(doc["assessment_limitations"]) + "\n\n" + ) + return preamble + limitations + scope_section + summary_section + table + waivers_md def _is_ascii(text: str) -> bool: diff --git a/tests/integration/test_audit_current_intent_contract.py b/tests/spec_conformance/test_audit_current_intent_contract.py similarity index 97% rename from tests/integration/test_audit_current_intent_contract.py rename to tests/spec_conformance/test_audit_current_intent_contract.py index 4cbf24c11e..210338e019 100644 --- a/tests/integration/test_audit_current_intent_contract.py +++ b/tests/spec_conformance/test_audit_current_intent_contract.py @@ -1,4 +1,4 @@ -"""Current-intent CLI controls awaiting the coordinated v0.2 requirement binding.""" +"""Current-intent and read-only CLI controls bound to the corrective assessment.""" from __future__ import annotations @@ -101,6 +101,7 @@ def no_network(*args: object, **kwargs: object) -> None: yield case +@pytest.mark.req("req-lk-023") @pytest.mark.parametrize("cold", [False, True], ids=["warm", "cold"]) def test_saved_target_drives_read_only_source_replay(installed: _AuditProject, cold: bool) -> None: """An unrelated detection signal must not replace the saved current target.""" @@ -112,6 +113,7 @@ def test_saved_target_drives_read_only_source_replay(installed: _AuditProject, c assert not (installed.project / ".agents/skills/intent").exists() +@pytest.mark.req("req-lk-023") def test_user_scope_replay_keeps_user_target_bytes( installed: _AuditProject, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -131,6 +133,7 @@ def test_user_scope_replay_keeps_user_target_bytes( assert all(check["passed"] for check in user.audit(0).values()) +@pytest.mark.req("req-lk-023") def test_full_audit_does_not_recreate_absent_configuration( installed: _AuditProject, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -146,6 +149,7 @@ def test_full_audit_does_not_recreate_absent_configuration( assert not config_path.exists() +@pytest.mark.req("req-lk-023") @pytest.mark.parametrize("invalid", ["claudee", [], 42, None]) @pytest.mark.parametrize("manifest_wins", [False, True]) def test_malformed_saved_intent_fails_only_when_selected( @@ -164,6 +168,7 @@ def test_malformed_saved_intent_fails_only_when_selected( assert checks["target-resolution"]["passed"] is False +@pytest.mark.req("req-lk-023") def test_native_workflow_replay_fails_before_writer( installed: _AuditProject, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -194,6 +199,7 @@ def test_native_workflow_replay_fails_before_writer( assert "scratch replay" in checks["drift"]["message"] +@pytest.mark.req("req-lk-023") @pytest.mark.parametrize( "mutation", [ @@ -309,6 +315,7 @@ def test_native_user_skill_replay_preserves_layout_and_comparison( assert neighbor.read_bytes() == b"untouched neighbor" +@pytest.mark.req("req-lk-023") @pytest.mark.parametrize("intent", ["manifest", "config", "detection"]) def test_current_intent_overrides_old_target_ownership( installed: _AuditProject, intent: str @@ -334,6 +341,7 @@ def test_current_intent_overrides_old_target_ownership( assert (installed.project / _DEPLOYED_SKILL).read_bytes() == installed.source.read_bytes() +@pytest.mark.req("req-lk-023") @pytest.mark.parametrize("invalid", ["disabled", "manifest"]) def test_invalid_current_selection_cannot_pass_empty_replay( installed: _AuditProject, invalid: str @@ -352,6 +360,7 @@ def test_invalid_current_selection_cannot_pass_empty_replay( assert "Unknown target 'grok-cloud'" in result.output +@pytest.mark.req("req-lk-023") @pytest.mark.parametrize("mutation", ["content", "missing-claim"]) def test_configured_target_keeps_integrity_and_membership_checks( installed: _AuditProject, mutation: str @@ -376,6 +385,7 @@ def test_configured_target_keeps_integrity_and_membership_checks( assert f"{kind}: {_DEPLOYED_SKILL}" in checks["drift"]["details"] +@pytest.mark.req("req-pl-016") def test_configured_target_does_not_authorize_invalid_owners(installed: _AuditProject) -> None: """Current target intent cannot bless an owner missing from the dependency set.""" path = installed.project / "apm.lock.yaml" diff --git a/tests/spec_conformance/test_spec_version_contract.py b/tests/spec_conformance/test_spec_version_contract.py index 70ee254a3c..767f97b224 100644 --- a/tests/spec_conformance/test_spec_version_contract.py +++ b/tests/spec_conformance/test_spec_version_contract.py @@ -238,3 +238,24 @@ def test_report_identity_and_links_follow_the_selected_assessment() -> None: requirement_links = re.findall(r"\[req-[^\]]+\]\(([^)]+)\)", markdown) assert len(requirement_links) == len(document["requirements"]) assert {urlparse(link).path for link in requirement_links} == {document["spec_path"]} + + +def test_corrective_revision_adds_only_the_approved_audit_requirement() -> None: + old = (_manifest.SPEC_DIR / "openapm-v0.1.md").read_text() + current = spec_text() + pattern = r'' + old_ids, new_ids = set(re.findall(pattern, old)), re.findall(pattern, current) + assert len(new_ids) == len(set(new_ids)) + assert set(new_ids) == old_ids | {"req-lk-023"} + assert len(new_ids) == len(_manifest.load_requirements()) + assert f"**{len(new_ids)} normative statements**" in current + assert f"**Total normative statements: {len(new_ids)}**" in current + + +def test_binding_inventory_discloses_unresolved_bare_audit_conformance_limit() -> None: + """A new assessed version cannot turn a known implementation gap into a pass.""" + document = gen_statement.build_json() + assert document["assessment_limitations"] == gen_statement.ASSESSMENT_LIMITATIONS + markdown = gen_statement.build_md(document) + assert "does not claim full Consumer conformance in bare audit mode" in markdown + assert "controlled pre-existing standalone-skill" in markdown From a036f37d5fbd2c35d9512f097a42f2b8f30bd9c3 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sun, 6 Sep 2026 19:00:02 +0200 Subject: [PATCH 11/34] spec: fold corrective review clarifications and evidence limits Apply formal round-2 synthesis F1/F3/F6/F7/F9/F10 only: audit-scoped explicit selection, local-content replay composition, directory-claim membership, retained schema and Git-tree evidence limitations, and exact citation wording. Preserve all IDs, old artifacts, schemas, and inherited integrity obligations. Integration owner retains companion tests, limitations, report regeneration, and publication. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/src/content/docs/specs/openapm-v0.2.md | 51 ++++++++++++++++++--- 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/docs/src/content/docs/specs/openapm-v0.2.md b/docs/src/content/docs/specs/openapm-v0.2.md index 047b0a3274..ed854d71e5 100644 --- a/docs/src/content/docs/specs/openapm-v0.2.md +++ b/docs/src/content/docs/specs/openapm-v0.2.md @@ -1251,10 +1251,14 @@ 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; directory claims retain their existing -membership semantics and containment boundaries. 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. +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. @@ -1264,6 +1268,15 @@ 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 @@ -1409,6 +1422,16 @@ 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. + > **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 @@ -2502,7 +2525,10 @@ 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 ` or via the manifest's `target:` field. In this revision +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. @@ -3792,7 +3818,7 @@ the assessed specification revision. ## Citing this specification External documents, tooling, and conformance statements MUST cite -this specification using a stable URL. Three shortlinks are +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 | @@ -3808,7 +3834,7 @@ 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. The exact v0.2.0 content route is `/specs/openapm-v020/`. -Any future patch needs a distinct artifact and exact route; it must not +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. @@ -3848,6 +3874,17 @@ 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) From f4110c884218b305d0a8593b9f8c3d3cdc1aa7b1 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sun, 6 Sep 2026 19:08:34 +0200 Subject: [PATCH 12/34] test(spec): preserve review evidence and explicit replay limitations Add saved explicit-only target and retained schema-boundary companions. Record the precise inherited internal-resource replay failure with a strict, exception-scoped expected failure after integrity and live-state assertions, plus an unsuppressed escaping-link refusal control. Regenerate exact-version bindings and document limitations without weakening normative requirements or production boundaries. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CONFORMANCE.json | 20 +++-- CONFORMANCE.md | 12 ++- docs/src/content/docs/reference/cli/audit.md | 5 ++ docs/src/content/docs/specs/conformance.md | 14 ++++ .../.apm/skills/apm-usage/commands.md | 4 + tests/spec_conformance/gen_statement.py | 14 ++++ .../test_audit_current_intent_contract.py | 84 +++++++++++++++++++ tests/spec_conformance/test_manifest_reqs.py | 12 +++ 8 files changed, 156 insertions(+), 9 deletions(-) diff --git a/CONFORMANCE.json b/CONFORMANCE.json index e68adb398e..3a1c4aa023 100644 --- a/CONFORMANCE.json +++ b/CONFORMANCE.json @@ -2,7 +2,10 @@ "assessment_limitations": [ "The reference CLI's bare content audit uses source-derived drift replay, not the stored-hash baseline required by req-lk-017's unqualified audit obligation. The stored-hash and full-SHA consistency baselines are exercised in CI/conformance audit. This inventory does not claim full Consumer conformance in bare audit mode.", "The native Cowork audit controls use a controlled pre-existing standalone-skill snapshot; they do not establish a successful Cowork install/audit round trip. The Grok user-scope control does exercise install and audit.", - "A selected native runtime without an isolated replay backend is reported as unsupported before its live writer. No new native database scratch backend or hosted-runtime evidence is supplied by this inventory." + "A selected native runtime without an isolated replay backend is reported as unsupported before its live writer. No new native database scratch backend or hosted-runtime evidence is supplied by this inventory.", + "A source-only coupled probe shows that local acquisition dereferences an admitted internal resource symlink, but inherited replay plans from the original source representation and can falsely report the deployed regular file as orphaned during unchanged CI audit. The narrowly expected-failing regression separately verifies content integrity and unchanged live state; an escaping-link refusal control remains unsuppressed. This is a replay limitation, not evidence of an escape, external-file read or security bypass.", + "The retained manifest schema rejects git entries with a path modifier and id entries with an explicit registry modifier. It also accepts malformed or wrong-length policy.hash strings structurally. Schema acceptance is not evidence of Consumer digest-envelope enforcement under req-mf-018 or req-lk-016.", + "Inherited Git-tree boundaries for symlink blobs, gitlinks/submodules and CRLF/LFS-filtered checkout bytes lack cross-platform execution evidence in this assessment. The req-lk-015 obligation and digest construction remain unchanged." ], "consumer_user_scope": { "lockfile_location": "~/.apm/apm.lock.yaml", @@ -325,8 +328,9 @@ "keyword": "MUST", "section": "5.5", "status": "active", - "test_count": 29, + "test_count": 31, "tests": [ + "tests/spec_conformance/test_audit_current_intent_contract.py::test_admitted_internal_resource_link_survives_unchanged_audit", "tests/spec_conformance/test_audit_current_intent_contract.py::test_configured_target_keeps_integrity_and_membership_checks[content]", "tests/spec_conformance/test_audit_current_intent_contract.py::test_configured_target_keeps_integrity_and_membership_checks[missing-claim]", "tests/spec_conformance/test_audit_current_intent_contract.py::test_current_intent_overrides_old_target_ownership[config]", @@ -353,6 +357,7 @@ "tests/spec_conformance/test_audit_current_intent_contract.py::test_native_user_skill_replay_preserves_layout_and_comparison[unavailable-root-no-config]", "tests/spec_conformance/test_audit_current_intent_contract.py::test_native_user_skill_replay_preserves_layout_and_comparison[unavailable-root]", "tests/spec_conformance/test_audit_current_intent_contract.py::test_native_workflow_replay_fails_before_writer", + "tests/spec_conformance/test_audit_current_intent_contract.py::test_saved_explicit_only_target_replays_without_detection", "tests/spec_conformance/test_audit_current_intent_contract.py::test_saved_target_drives_read_only_source_replay[cold]", "tests/spec_conformance/test_audit_current_intent_contract.py::test_saved_target_drives_read_only_source_replay[warm]", "tests/spec_conformance/test_audit_current_intent_contract.py::test_user_scope_replay_keeps_user_target_bytes" @@ -530,8 +535,9 @@ "keyword": "MUST", "section": "4.3.5", "status": "active", - "test_count": 46, + "test_count": 47, "tests": [ + "tests/spec_conformance/test_audit_current_intent_contract.py::test_local_resource_escape_is_rejected_before_audit", "tests/spec_conformance/test_local_path_reqs.py::test_direct_local_source_is_anchored_before_copy[project-relative]", "tests/spec_conformance/test_local_path_reqs.py::test_direct_local_source_is_anchored_before_copy[user-absolute]", "tests/spec_conformance/test_local_path_reqs.py::test_direct_local_source_is_anchored_before_copy[user-home]", @@ -597,9 +603,11 @@ "keyword": "MUST", "section": "4.6.1", "status": "active", - "test_count": 1, + "test_count": 3, "tests": [ - "tests/spec_conformance/test_manifest_reqs.py::test_consumer_restricts_policy_hash_algorithm_to_strong_set" + "tests/spec_conformance/test_manifest_reqs.py::test_consumer_restricts_policy_hash_algorithm_to_strong_set", + "tests/spec_conformance/test_manifest_reqs.py::test_retained_schema_accepts_invalid_policy_hash_without_semantic_evidence[not-a-digest]", + "tests/spec_conformance/test_manifest_reqs.py::test_retained_schema_accepts_invalid_policy_hash_without_semantic_evidence[sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa]" ] }, { @@ -1501,7 +1509,7 @@ ], "spec_citation": "https://microsoft.github.io/apm/spec/v0.2.0", "spec_path": "docs/src/content/docs/specs/openapm-v0.2.md", - "spec_sha256": "2a0dd8bb0a807d02f18cd19ba2c5316ff82467205aae906e352478a13b868cd6", + "spec_sha256": "71c7a98b62d1aae7cbcd70f93ec2d59c17e5e2caefbc0810198366510302610e", "spec_version": "v0.2.0", "summary_by_class": { "consumer": { diff --git a/CONFORMANCE.md b/CONFORMANCE.md index e21e5d6d76..329e26ccc1 100644 --- a/CONFORMANCE.md +++ b/CONFORMANCE.md @@ -25,6 +25,12 @@ The native Cowork audit controls use a controlled pre-existing standalone-skill A selected native runtime without an isolated replay backend is reported as unsupported before its live writer. No new native database scratch backend or hosted-runtime evidence is supplied by this inventory. +A source-only coupled probe shows that local acquisition dereferences an admitted internal resource symlink, but inherited replay plans from the original source representation and can falsely report the deployed regular file as orphaned during unchanged CI audit. The narrowly expected-failing regression separately verifies content integrity and unchanged live state; an escaping-link refusal control remains unsuppressed. This is a replay limitation, not evidence of an escape, external-file read or security bypass. + +The retained manifest schema rejects git entries with a path modifier and id entries with an explicit registry modifier. It also accepts malformed or wrong-length policy.hash strings structurally. Schema acceptance is not evidence of Consumer digest-envelope enforcement under req-mf-018 or req-lk-016. + +Inherited Git-tree boundaries for symlink blobs, gitlinks/submodules and CRLF/LFS-filtered checkout bytes lack cross-platform execution evidence in this assessment. The req-lk-015 obligation and digest construction remain unchanged. + ## Consumer user-scope disclosure - Manifest: `~/.apm/apm.yml` @@ -70,7 +76,7 @@ A selected native runtime without an isolated replay backend is reported as unsu | [req-lk-020](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-020) | MUST | 5.2 | consumer | active | 3 | - | | [req-lk-021](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-021) | MUST | 5.2 | consumer | active | 2 | - | | [req-lk-022](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-022) | MUST | 5.2 | consumer | active | 4 | - | -| [req-lk-023](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-023) | MUST | 5.5 | consumer | active | 29 | - | +| [req-lk-023](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-023) | MUST | 5.5 | consumer | active | 31 | - | | [req-mf-001](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-001) | MUST | 4.1 | producer | active | 1 | - | | [req-mf-002](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-002) | MUST | 4.1 | producer | active | 1 | - | | [req-mf-003](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-003) | MUST | 4.1 | producer | active | 1 | - | @@ -86,9 +92,9 @@ A selected native runtime without an isolated replay backend is reported as unsu | [req-mf-013](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-013) | MUST | 4.5 | consumer | active | 1 | - | | [req-mf-014](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-014) | MUST | 4.2.3 | producer | active | 1 | - | | [req-mf-015](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-015) | MUST | 4.2.3 | producer | active | 1 | - | -| [req-mf-016](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-016) | MUST | 4.3.5 | consumer | active | 46 | - | +| [req-mf-016](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-016) | MUST | 4.3.5 | consumer | active | 47 | - | | [req-mf-017](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-017) | MUST | 4.7 | producer | active | 1 | - | -| [req-mf-018](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-018) | MUST | 4.6.1 | consumer | active | 1 | - | +| [req-mf-018](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-018) | MUST | 4.6.1 | consumer | active | 3 | - | | [req-mf-019](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-019) | MUST | 4.2.4 | consumer | active | 1 | - | | [req-mf-020](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-020) | MUST | 4.1 | consumer | active | 1 | - | | [req-mf-021](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-021) | MUST | 4.8 | producer | active | 1 | - | diff --git a/docs/src/content/docs/reference/cli/audit.md b/docs/src/content/docs/reference/cli/audit.md index 2f9e71297a..4040c669c0 100644 --- a/docs/src/content/docs/reference/cli/audit.md +++ b/docs/src/content/docs/reference/cli/audit.md @@ -205,6 +205,11 @@ modify their database or sidecars. Audit leaves absent user configuration absent and existing configuration unchanged, including during package parsing, MCP inspection, and content scanning. +Known limitation: a local package's internal resource symlink can install as +regular-file content yet be reported as `orphaned` by unchanged CI audit. +This is a replay mismatch, not permission to follow escaping links. +See the [conformance limitations](../../../specs/conformance/#what-conformance-does-not-cover). + The default audit replays the install pipeline into a scratch tree and diffs the result against the working tree. It catches hand-edits, missing integrations, orphaned files, and `unrecorded` files. `unrecorded` applies when diff --git a/docs/src/content/docs/specs/conformance.md b/docs/src/content/docs/specs/conformance.md index 02f78ccd0d..0d0fb065da 100644 --- a/docs/src/content/docs/specs/conformance.md +++ b/docs/src/content/docs/specs/conformance.md @@ -38,4 +38,18 @@ Current local-source cases assess the corrective req-mf-016 only. They do not pr The audit cases bind current target intent and read-only replay to req-lk-023. They do not erase inherited integrity requirements: the CLI's bare content audit uses source-derived drift, while its stored-hash and full-SHA consistency baselines run in CI/conformance audit. The unqualified audit obligation in req-lk-017 remains an explicit bare-mode conformance limitation. Native Cowork cases use pre-existing fixture state, not a successful native install round trip. +One coupled source-level case is a strict expected failure: local installation +correctly dereferences an internal resource link, but unchanged CI audit can +report that deployed regular file as orphaned. The case still checks content +integrity and unchanged project/HOME state; escaping links have a separate, +unsuppressed refusal control. This limitation is not a conformance pass or a +symlink-containment exception. + +The retained manifest schema is also incomplete as an acceptance oracle: it +rejects some source-plus-modifier forms and structurally accepts invalid +`policy.hash` strings. Those schema probes do not prove runtime digest +enforcement. Git symlink, submodule and checkout-filter hashing boundaries lack +cross-platform execution evidence here. The normative obligations remain intact; +the generated inventory lists these limitations rather than waiving them. + For the broader drift-detection story (how the spec authors prevent the spec from rotting relative to the only implementation), see [`CONTRIBUTING.md` -- Spec amendment workflow](https://github.com/microsoft/apm/blob/main/CONTRIBUTING.md#spec-amendment-workflow). diff --git a/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md index 490fd6e6b2..e118802b2b 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/commands.md +++ b/packages/apm-guide/.apm/skills/apm-usage/commands.md @@ -238,6 +238,10 @@ closed. Missing ownership never removes source-derived expectations. See the [audit reference](https://microsoft.github.io/apm/reference/cli/audit/#drift-detection) for target prerequisites and replay boundaries. +Known replay limitation: an internal local resource link copied as a regular +file during install can be falsely reported as `orphaned` by unchanged CI audit. +Escaping resource links remain rejected; this is not a containment exception. + | Command | Purpose | Key flags | |---------|---------|-----------| | `apm audit [PKG]` | Scan installed primitives for hidden Unicode, drift, and lockfile/policy violations | `--file PATH`, `--strip`, `--dry-run`, `-v`, `-f [text\|json\|sarif\|md]`, `-o PATH`, `--ci`, `--policy SOURCE`, `--no-cache`, `--no-fail-fast`, `--no-drift`, `--external NAME` (experimental; ingest a third-party SARIF scanner, e.g. `skillspector`), `--external-sarif PATH`, `--external-llm/--no-external-llm`, `--external-args TEXT` | diff --git a/tests/spec_conformance/gen_statement.py b/tests/spec_conformance/gen_statement.py index c97e37cd3d..85b8d35db7 100644 --- a/tests/spec_conformance/gen_statement.py +++ b/tests/spec_conformance/gen_statement.py @@ -52,6 +52,20 @@ "A selected native runtime without an isolated replay backend is reported as " "unsupported before its live writer. No new native database scratch backend " "or hosted-runtime evidence is supplied by this inventory.", + "A source-only coupled probe shows that local acquisition dereferences an admitted " + "internal resource symlink, but inherited replay plans from the original source " + "representation and can falsely report the deployed regular file as orphaned " + "during unchanged CI audit. The narrowly expected-failing regression separately " + "verifies content integrity and unchanged live state; an escaping-link refusal " + "control remains unsuppressed. This is a replay limitation, not evidence of an " + "escape, external-file read or security bypass.", + "The retained manifest schema rejects git entries with a path modifier and id " + "entries with an explicit registry modifier. It also accepts malformed or " + "wrong-length policy.hash strings structurally. Schema acceptance is not evidence " + "of Consumer digest-envelope enforcement under req-mf-018 or req-lk-016.", + "Inherited Git-tree boundaries for symlink blobs, gitlinks/submodules and " + "CRLF/LFS-filtered checkout bytes lack cross-platform execution evidence in this " + "assessment. The req-lk-015 obligation and digest construction remain unchanged.", ] diff --git a/tests/spec_conformance/test_audit_current_intent_contract.py b/tests/spec_conformance/test_audit_current_intent_contract.py index 210338e019..887742e3b3 100644 --- a/tests/spec_conformance/test_audit_current_intent_contract.py +++ b/tests/spec_conformance/test_audit_current_intent_contract.py @@ -19,6 +19,8 @@ from apm_cli import config from apm_cli.cli import cli +from apm_cli.install.audit_target_roots import resolve_audit_targets +from apm_cli.integration.targets import resolve_targets from apm_cli.utils import console from apm_cli.utils.yaml_io import dump_yaml, load_yaml from tests.utils.artifact_snapshot import ArtifactSnapshot, assert_unchanged @@ -396,3 +398,85 @@ def test_configured_target_does_not_authorize_invalid_owners(installed: _AuditPr dump_yaml(document, path) checks = installed.audit(1) assert checks["deployment-ledger-owners"]["passed"] is False + + +@pytest.mark.req("req-lk-023") +def test_saved_explicit_only_target_replays_without_detection( + installed: _AuditProject, monkeypatch: pytest.MonkeyPatch +) -> None: + """Saved agent-skills intent is explicit selection, not a detection predicate.""" + project = ( + LocalPackageFactory(installed.project.parent) + .create("explicit-only-consumer", dependencies=[{"path": str(installed.source.parents[2])}]) + .root + ) + case = replace(installed, project=project) + monkeypatch.chdir(project) + manifest = load_yaml(project / "apm.yml") + assert "target" not in manifest and "targets" not in manifest + assert case.command("config", "set", "target", "agent-skills").exit_code == 0 + result = case.command("install", "--no-policy", "--parallel-downloads", "0") + assert result.exit_code == 0, result.output + assert {target.name for target in resolve_audit_targets(project)} == {"agent-skills"} + assert "agent-skills" not in { + target.name for target in resolve_targets(project, create_config=False) + } + deployed = project / ".agents/skills/intent/SKILL.md" + assert deployed.read_bytes() == case.source.read_bytes() + assert all(check["passed"] for check in case.audit(0).values()) + + +class _KnownInternalLinkReplayGap(AssertionError): + """Only the demonstrated false-orphan outcome is an expected failure.""" + + +@pytest.mark.req("req-lk-023") +@pytest.mark.xfail( + strict=True, + raises=_KnownInternalLinkReplayGap, + reason="Inherited replay omits an internal resource link dereferenced by local acquisition", +) +def test_admitted_internal_resource_link_survives_unchanged_audit( + installed: _AuditProject, +) -> None: + """Acquired regular-file content should not become obsolete during replay.""" + payload = installed.source.parent / "payload.txt" + payload.write_bytes(b"Internal resource payload\n") + (installed.source.parent / "linked.txt").symlink_to("payload.txt") + result = installed.command("install", "--no-policy", "--parallel-downloads", "0") + assert result.exit_code == 0, result.output + relative = ".grok/skills/intent/linked.txt" + deployed = installed.project / relative + assert deployed.is_file() and not deployed.is_symlink() + assert deployed.read_bytes() == payload.read_bytes() + + project_before = ArtifactSnapshot.capture(installed.project) + home_before = ArtifactSnapshot.capture(installed.home) + result = installed.command("audit", "--ci", "--no-policy", "--no-fail-fast", "-f", "json") + assert_unchanged(project_before, ArtifactSnapshot.capture(installed.project)) + assert_unchanged(home_before, ArtifactSnapshot.capture(installed.home)) + checks = {row["name"]: row for row in json.loads(result.stdout)["checks"]} + assert checks["content-integrity"]["passed"] is True, checks + assert all(row["passed"] for name, row in checks.items() if name != "drift"), checks + if ( + result.exit_code == 1 + and checks["drift"]["passed"] is False + and checks["drift"]["details"] == [f"orphaned: {relative}"] + ): + raise _KnownInternalLinkReplayGap( + "Unchanged CI audit must preserve admitted linked.txt content, not report it orphaned" + ) + assert result.exit_code == 0, result.output + assert checks["drift"]["passed"] is True + + +@pytest.mark.req("req-mf-016") +def test_local_resource_escape_is_rejected_before_audit(installed: _AuditProject) -> None: + """The replay limitation does not permit acquiring an escaping resource link.""" + outside = installed.source.parents[2].parent / "outside.txt" + outside.write_bytes(b"Outside selected package\n") + (installed.source.parent / "escaped.txt").symlink_to(outside) + result = installed.command("install", "--no-policy", "--parallel-downloads", "0") + assert result.exit_code != 0, result.output + assert outside.read_bytes() == b"Outside selected package\n" + assert not (installed.project / ".grok/skills/intent/escaped.txt").exists() diff --git a/tests/spec_conformance/test_manifest_reqs.py b/tests/spec_conformance/test_manifest_reqs.py index 6cec4e6f08..72c71ca724 100644 --- a/tests/spec_conformance/test_manifest_reqs.py +++ b/tests/spec_conformance/test_manifest_reqs.py @@ -191,6 +191,18 @@ def test_consumer_restricts_policy_hash_algorithm_to_strong_set(): assert set(enum) == {"sha256", "sha384", "sha512"} +@pytest.mark.req("req-mf-018") +@pytest.mark.parametrize("digest", ["not-a-digest", "sha256:" + "a" * 63]) +def test_retained_schema_accepts_invalid_policy_hash_without_semantic_evidence( + digest: str, +) -> None: + """Structural acceptance is a schema limitation, not consumer hash enforcement.""" + document = load_yaml_fixture("manifest", "valid-minimal.yml") + document["policy"] = {"hash_algorithm": "sha256", "hash": digest} + validate_against("manifest-v0.1.schema.json", document) + assert_spec_contains('id="req-mf-018"', 'id="req-lk-016"', "`policy.hash`") + + @pytest.mark.req("req-mf-019") def test_consumer_supports_default_host_field(): schema = load_schema("manifest-v0.1.schema.json") From a099b6909a714a6ea561b1abc8ef5d3b44c19a0d Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sun, 6 Sep 2026 19:12:16 +0200 Subject: [PATCH 13/34] docs(spec): distinguish original audit cases from review companions Keep the selected manifest note truthful after adding explicit-only and narrowly expected-failing replay companions. Regenerate exact manifest fingerprints; no normative or runtime change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CONFORMANCE.json | 2 +- docs/public/specs/manifests/openapm-v0.2.requirements.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CONFORMANCE.json b/CONFORMANCE.json index 3a1c4aa023..930d550011 100644 --- a/CONFORMANCE.json +++ b/CONFORMANCE.json @@ -14,7 +14,7 @@ }, "generator": "gen_statement.py v2", "inventory_kind": "static-test-bindings", - "manifest_sha256": "c0903dc53f2dcadfa61716d376f32383796ba21a57fd3e199548c674961f3052", + "manifest_sha256": "3b1e106cd029ab2e56beda2f5fde82400a8150ba29a627665339846813b0a47e", "requirements": [ { "conformance_class": "consumer", diff --git a/docs/public/specs/manifests/openapm-v0.2.requirements.yml b/docs/public/specs/manifests/openapm-v0.2.requirements.yml index c4b815b275..aa1783d053 100644 --- a/docs/public/specs/manifests/openapm-v0.2.requirements.yml +++ b/docs/public/specs/manifests/openapm-v0.2.requirements.yml @@ -229,7 +229,7 @@ requirements: 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. test_audit_current_intent_contract.py carries 29 genuine replay/current-intent cases; its separate invalid-owner case binds req-pl-016." + 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. test_audit_current_intent_contract.py preserves the original 29 replay/current-intent cases and adds an explicit-only selection case plus a narrowly expected-failing internal-link replay case; its separate invalid-owner case binds req-pl-016." - id: req-pl-001 keyword: MUST section: "6.1" From 300a24ac7ee2513a112428721c0f6b968ca13e84 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sun, 6 Sep 2026 19:53:06 +0200 Subject: [PATCH 14/34] fix(audit): keep startup and cold replay read-only Skip audit's opportunistic update check and opt cold replay into noncreating transport-config reads without changing install defaults or transport precedence. Add warm/cold installed-CLI and historical-native refusal controls, and preserve the symlink-root refusal's structured result. Normalize only the repository lock's two machine-local native URI claims and matching ledger rows; no live native state is removed, reconciled, or declared absent. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CONFORMANCE.json | 7 ++- CONFORMANCE.md | 2 +- apm.lock.yaml | 20 ------- .../manifests/openapm-v0.2.requirements.yml | 2 +- docs/src/content/docs/reference/cli/audit.md | 3 + .../.apm/skills/apm-usage/commands.md | 2 + src/apm_cli/cli.py | 3 +- src/apm_cli/deps/github_downloader.py | 8 ++- src/apm_cli/install/drift.py | 2 +- .../test_audit_target_intent_e2e.py | 45 ++++++++++++++ ...test_audit_unrecorded_unicode_lifecycle.py | 15 ++++- .../integration/test_version_notification.py | 13 +++- .../test_audit_current_intent_contract.py | 54 +++++++++++++++++ tests/unit/test_protocol_config_precedence.py | 60 ++++++++++++++++++- 14 files changed, 204 insertions(+), 32 deletions(-) diff --git a/CONFORMANCE.json b/CONFORMANCE.json index 930d550011..a459933f7f 100644 --- a/CONFORMANCE.json +++ b/CONFORMANCE.json @@ -14,7 +14,7 @@ }, "generator": "gen_statement.py v2", "inventory_kind": "static-test-bindings", - "manifest_sha256": "3b1e106cd029ab2e56beda2f5fde82400a8150ba29a627665339846813b0a47e", + "manifest_sha256": "699fa2eaec138d150d24f18fe9fd6254bab86e46319c4880fdcaad304fe18046", "requirements": [ { "conformance_class": "consumer", @@ -328,7 +328,7 @@ "keyword": "MUST", "section": "5.5", "status": "active", - "test_count": 31, + "test_count": 34, "tests": [ "tests/spec_conformance/test_audit_current_intent_contract.py::test_admitted_internal_resource_link_survives_unchanged_audit", "tests/spec_conformance/test_audit_current_intent_contract.py::test_configured_target_keeps_integrity_and_membership_checks[content]", @@ -337,6 +337,9 @@ "tests/spec_conformance/test_audit_current_intent_contract.py::test_current_intent_overrides_old_target_ownership[detection]", "tests/spec_conformance/test_audit_current_intent_contract.py::test_current_intent_overrides_old_target_ownership[manifest]", "tests/spec_conformance/test_audit_current_intent_contract.py::test_full_audit_does_not_recreate_absent_configuration", + "tests/spec_conformance/test_audit_current_intent_contract.py::test_historical_native_claims_cannot_pass_filesystem_only_audit[both]", + "tests/spec_conformance/test_audit_current_intent_contract.py::test_historical_native_claims_cannot_pass_filesystem_only_audit[canonical]", + "tests/spec_conformance/test_audit_current_intent_contract.py::test_historical_native_claims_cannot_pass_filesystem_only_audit[legacy]", "tests/spec_conformance/test_audit_current_intent_contract.py::test_invalid_current_selection_cannot_pass_empty_replay[disabled]", "tests/spec_conformance/test_audit_current_intent_contract.py::test_invalid_current_selection_cannot_pass_empty_replay[manifest]", "tests/spec_conformance/test_audit_current_intent_contract.py::test_malformed_saved_intent_fails_only_when_selected[False-42]", diff --git a/CONFORMANCE.md b/CONFORMANCE.md index 329e26ccc1..15521f7b7b 100644 --- a/CONFORMANCE.md +++ b/CONFORMANCE.md @@ -76,7 +76,7 @@ Inherited Git-tree boundaries for symlink blobs, gitlinks/submodules and CRLF/LF | [req-lk-020](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-020) | MUST | 5.2 | consumer | active | 3 | - | | [req-lk-021](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-021) | MUST | 5.2 | consumer | active | 2 | - | | [req-lk-022](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-022) | MUST | 5.2 | consumer | active | 4 | - | -| [req-lk-023](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-023) | MUST | 5.5 | consumer | active | 31 | - | +| [req-lk-023](docs/src/content/docs/specs/openapm-v0.2.md#req-lk-023) | MUST | 5.5 | consumer | active | 34 | - | | [req-mf-001](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-001) | MUST | 4.1 | producer | active | 1 | - | | [req-mf-002](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-002) | MUST | 4.1 | producer | active | 1 | - | | [req-mf-003](docs/src/content/docs/specs/openapm-v0.2.md#req-mf-003) | MUST | 4.1 | producer | active | 1 | - | diff --git a/apm.lock.yaml b/apm.lock.yaml index d2ee6e1c7c..1a014e652e 100644 --- a/apm.lock.yaml +++ b/apm.lock.yaml @@ -35,7 +35,6 @@ dependencies: - .agents/skills/apm-issue-autopilot/evals/real-task-refinement.md - .agents/skills/apm-issue-autopilot/evals/triggers.json - .agents/skills/apm-issue-autopilot/scripts/run_evals.py - - copilot-app-db://workflows/apm--_local--apm-issue-autopilot--apm-issue-autopilot deployed_file_hashes: .agents/skills/apm-issue-autopilot/SKILL.md: sha256:9872bd7ada9903073c663a5b1d1bfaa15743d4bcb2f450631a4da610b1fd0a6e .agents/skills/apm-issue-autopilot/assets/acceptance-observer.md: sha256:e4ddab439f62bb372f5ad608775294dd44653ef475eeca8ef5517af7f5e42699 @@ -95,7 +94,6 @@ dependencies: - .agents/skills/batch-bug-shepherd/references/invariants.md - .agents/skills/batch-bug-shepherd/references/strategic-alignment-gate.md - .agents/skills/batch-bug-shepherd/scripts/run_evals.py - - copilot-app-db://workflows/apm--_local--batch-bug-shepherd--batch-bug-shepherd deployed_file_hashes: .agents/skills/batch-bug-shepherd/SKILL.md: sha256:c5b0f8752e726d123f61491c959681134882dfddbc9f5d15ac5c47e55931375f .agents/skills/batch-bug-shepherd/assets/final-report-template.md: sha256:ea280a4d1e15a9b56ba8d3ef5f0e931754cc44ae29d5615da58da8d445f455c2 @@ -1649,24 +1647,6 @@ deployments: - . active_owner: . content_hash: sha256:55ef10cd0f6b68e0db5a435a79851c055eab74d93fd552c6626df631deb51df4 -- kind: uri - target: copilot-app - value: copilot-app-db://workflows/apm--_local--apm-issue-autopilot--apm-issue-autopilot - runtime: null - scope: project - owners: - - ./packages/apm-issue-autopilot - active_owner: ./packages/apm-issue-autopilot - content_hash: null -- kind: uri - target: copilot-app - value: copilot-app-db://workflows/apm--_local--batch-bug-shepherd--batch-bug-shepherd - runtime: null - scope: project - owners: - - ./packages/batch-bug-shepherd - active_owner: ./packages/batch-bug-shepherd - content_hash: null - kind: project-relative target: copilot value: .agents/skills/apm-issue-autopilot diff --git a/docs/public/specs/manifests/openapm-v0.2.requirements.yml b/docs/public/specs/manifests/openapm-v0.2.requirements.yml index aa1783d053..b5f7ed88a0 100644 --- a/docs/public/specs/manifests/openapm-v0.2.requirements.yml +++ b/docs/public/specs/manifests/openapm-v0.2.requirements.yml @@ -229,7 +229,7 @@ requirements: 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. test_audit_current_intent_contract.py preserves the original 29 replay/current-intent cases and adds an explicit-only selection case plus a narrowly expected-failing internal-link replay case; its separate invalid-owner case binds req-pl-016." + 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. test_audit_current_intent_contract.py preserves the original 29 replay/current-intent cases and adds explicit-only selection, historical native ownership refusal, and narrowly expected-failing internal-link replay companions; its separate invalid-owner case binds req-pl-016." - id: req-pl-001 keyword: MUST section: "6.1" diff --git a/docs/src/content/docs/reference/cli/audit.md b/docs/src/content/docs/reference/cli/audit.md index 4040c669c0..7362b9c3a0 100644 --- a/docs/src/content/docs/reference/cli/audit.md +++ b/docs/src/content/docs/reference/cli/audit.md @@ -204,6 +204,9 @@ modify their database or sidecars. Audit leaves absent user configuration absent and existing configuration unchanged, including during package parsing, MCP inspection, and content scanning. +Cold replay also reads transport preferences without creating configuration. +It also skips opportunistic startup update checks, which otherwise write an +update cache; use `apm self-update --check` separately. Known limitation: a local package's internal resource symlink can install as regular-file content yet be reported as `orphaned` by unchanged CI audit. diff --git a/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md index e118802b2b..5b8385b565 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/commands.md +++ b/packages/apm-guide/.apm/skills/apm-usage/commands.md @@ -228,6 +228,8 @@ set target` > existing directory detection. A selected malformed saved target fails resolution; a valid manifest target overrides irrelevant invalid saved configuration. Audit does not recover unsaved historical `install --target` overrides or create missing user configuration. +Audit skips startup update checks and their cache writes; use +`apm self-update --check` separately. Saved target names do not guarantee scratch replay support. Native nonfilesystem targets without an isolated filesystem backend (currently `copilot-app`) fail diff --git a/src/apm_cli/cli.py b/src/apm_cli/cli.py index 7f6978aab3..bac205fd9b 100644 --- a/src/apm_cli/cli.py +++ b/src/apm_cli/cli.py @@ -170,9 +170,10 @@ def cli(ctx, verbose: bool) -> None: warnings.filterwarnings("ignore", category=AgentsTargetDeprecationWarning) # Check for updates only for known commands; skip on invalid input to fail fast. + # Read-only audit also excludes the update check's cache/configuration writes. if ( not ctx.resilient_parsing - and ctx.invoked_subcommand is not None + and ctx.invoked_subcommand not in (None, "audit") and ctx.command.get_command(ctx, ctx.invoked_subcommand) is not None ): _check_and_notify_updates() diff --git a/src/apm_cli/deps/github_downloader.py b/src/apm_cli/deps/github_downloader.py index aa355ee454..a34655f1e0 100644 --- a/src/apm_cli/deps/github_downloader.py +++ b/src/apm_cli/deps/github_downloader.py @@ -205,6 +205,8 @@ def __init__( transport_selector: TransportSelector | None = None, protocol_pref: ProtocolPreference | None = None, allow_fallback: bool | None = None, + *, + create_config: bool = True, ): """Initialize the GitHub package downloader. @@ -221,6 +223,8 @@ def __init__( ``APM_ALLOW_PROTOCOL_FALLBACK`` env var, then ``allow-protocol-fallback`` in ``~/.apm/config.json``, then ``False``. + create_config: Whether transport preference reads may initialize + missing user configuration. Read-only replay passes False. """ self.auth_resolver = auth_resolver or AuthResolver() self.token_manager = self.auth_resolver._token_manager # Backward compat @@ -235,7 +239,7 @@ def __init__( from ..config import get_apm_protocol_pref as _get_pref from .transport_selection import ProtocolPreference - _pref_str = _get_pref() + _pref_str = _get_pref(create_config=create_config) self._protocol_pref = ProtocolPreference.from_str(_pref_str) if allow_fallback is not None: self._allow_fallback = allow_fallback @@ -243,7 +247,7 @@ def __init__( # Config-aware helper (env > apm config > False). from ..config import get_apm_allow_protocol_fallback as _get_fallback - self._allow_fallback = _get_fallback() + self._allow_fallback = _get_fallback(create_config=create_config) # Dedup set for the issue #786 cross-protocol port warning: one install # run calls _clone_with_fallback multiple times per dep (ref-resolution # clone, then the actual dep clone). We want the warning exactly once diff --git a/src/apm_cli/install/drift.py b/src/apm_cli/install/drift.py index 59065efc73..4fde0c5033 100644 --- a/src/apm_cli/install/drift.py +++ b/src/apm_cli/install/drift.py @@ -572,7 +572,7 @@ def run_replay(config: ReplayConfig, logger: CheckLogger) -> Path: from apm_cli.deps.registry.resolver import RegistryPackageResolver from apm_cli.models.apm_package import APMPackage - downloader = GitHubPackageDownloader(auth_resolver=AuthResolver()) + downloader = GitHubPackageDownloader(auth_resolver=AuthResolver(), create_config=False) apm_yml = project_root / "apm.yml" if apm_yml.exists(): manifest = APMPackage.from_apm_yml(apm_yml, create_config=False) diff --git a/tests/integration/test_audit_target_intent_e2e.py b/tests/integration/test_audit_target_intent_e2e.py index 4b8fe89e2f..98f0a1c3eb 100644 --- a/tests/integration/test_audit_target_intent_e2e.py +++ b/tests/integration/test_audit_target_intent_e2e.py @@ -257,3 +257,48 @@ def test_configured_grok_cloud_user_scope_audit(tmp_path: Path, apm_binary_path: before_home = ArtifactSnapshot.capture(scenario.isolated.home) assert user.audit()["passed"] is True assert_unchanged(before_home, ArtifactSnapshot.capture(scenario.isolated.home)) + + +@pytest.mark.parametrize("failed_replay", [False, True], ids=["clean", "unsupported-native"]) +@pytest.mark.parametrize("cold_cache", [False, True], ids=["warm", "cold"]) +def test_audit_startup_leaves_fresh_home_unchanged_outside_test_mode( + tmp_path: Path, apm_binary_path: Path, failed_replay: bool, cold_cache: bool +) -> None: + """The real entry point stays read-only without test-only update suppression.""" + scenario = _install_grok(tmp_path, apm_binary_path) + manifest_path = scenario.project / "apm.yml" + manifest = load_yaml(manifest_path) + manifest["targets"] = ["grok-build"] + dump_yaml(manifest, manifest_path) + (scenario.isolated.config_root / "config.json").unlink() + if cold_cache: + shutil.rmtree(scenario.project / "apm_modules") + if failed_replay: + lock_path = scenario.project / "apm.lock.yaml" + document = load_yaml(lock_path) + document["dependencies"][0]["deployed_files"].append( + "copilot-app-db://workflows/audit-startup-fixture" + ) + dump_yaml(document, lock_path) + + environment = scenario.isolated.subprocess_env() + environment.pop("PYTEST_CURRENT_TEST", None) + environment.pop("APM_E2E_TESTS", None) + before_project = ArtifactSnapshot.capture(scenario.project) + before_home = ArtifactSnapshot.capture(scenario.isolated.home) + result = scenario.runner.run( + _AUDIT_ARGS, + scenario_id="audit-without-test-mode", + cwd=scenario.project, + env=environment, + ) + assert result.returncode == int(failed_replay), result.stdout + result.stderr + checks = {row["name"]: row for row in json.loads(result.stdout)["checks"]} + assert checks["content-integrity"]["passed"] is True + if failed_replay: + assert checks["drift"]["passed"] is False + assert "no isolated filesystem scratch replay backend" in checks["drift"]["message"] + else: + assert all(check["passed"] for check in checks.values()) + assert_unchanged(before_project, ArtifactSnapshot.capture(scenario.project)) + assert_unchanged(before_home, ArtifactSnapshot.capture(scenario.isolated.home)) diff --git a/tests/integration/test_audit_unrecorded_unicode_lifecycle.py b/tests/integration/test_audit_unrecorded_unicode_lifecycle.py index 9ed45c1246..6dbd83d7b8 100644 --- a/tests/integration/test_audit_unrecorded_unicode_lifecycle.py +++ b/tests/integration/test_audit_unrecorded_unicode_lifecycle.py @@ -262,12 +262,23 @@ def test_symlinked_deploy_root_never_scans_outside_project( audit = _run( lifecycle, - _CI_AUDIT, + (*_CI_AUDIT, "--format", "json"), expected=1, scenario_id="symlinked-deploy-root-contained", ) assert _UNRECORDED not in audit.stdout - assert audit.stderr == "Error: Refusing deployment through symlinked target root: .claude\n" + report = json.loads(audit.stdout) + failures = [check for check in report["checks"] if not check["passed"]] + assert report["passed"] is False + assert failures == [ + { + "name": "target-resolution", + "passed": False, + "message": "Refusing deployment through symlinked target root: .claude", + "details": [], + } + ] + assert audit.stderr == "" assert payload.read_bytes() == _BIDI_BYTES diff --git a/tests/integration/test_version_notification.py b/tests/integration/test_version_notification.py index 81b4dfc2c2..88f3946e11 100644 --- a/tests/integration/test_version_notification.py +++ b/tests/integration/test_version_notification.py @@ -2,7 +2,7 @@ import os # noqa: F401 import unittest -from unittest.mock import patch +from unittest.mock import MagicMock, patch import click from click.testing import CliRunner @@ -116,6 +116,17 @@ def test_callback_skips_update_check_without_subcommand(self, mock_check): mock_check.assert_not_called() + @patch("apm_cli.cli._check_and_notify_updates") + def test_audit_callback_skips_startup_update_check(self, mock_check: MagicMock) -> None: + """Read-only audit must not invoke update-cache or configuration writes.""" + from apm_cli.cli import cli + + ctx = click.Context(cli, info_name="apm") + ctx.invoked_subcommand = "audit" + with ctx: + cli.callback(verbose=False) + mock_check.assert_not_called() + class TestUpdateCommand(unittest.TestCase): """Test the update command.""" diff --git a/tests/spec_conformance/test_audit_current_intent_contract.py b/tests/spec_conformance/test_audit_current_intent_contract.py index 887742e3b3..662063e3ce 100644 --- a/tests/spec_conformance/test_audit_current_intent_contract.py +++ b/tests/spec_conformance/test_audit_current_intent_contract.py @@ -426,6 +426,60 @@ def test_saved_explicit_only_target_replays_without_detection( assert all(check["passed"] for check in case.audit(0).values()) +@pytest.mark.req("req-lk-023") +@pytest.mark.parametrize("representation", ["canonical", "legacy", "both"]) +def test_historical_native_claims_cannot_pass_filesystem_only_audit( + installed: _AuditProject, monkeypatch: pytest.MonkeyPatch, representation: str +) -> None: + """Portable filesystem intent cannot silently discard retained native ownership.""" + from apm_cli.integration.copilot_app_workflow_integrator import CopilotAppWorkflowIntegrator + + manifest_path = installed.project / "apm.yml" + manifest = load_yaml(manifest_path) + manifest["targets"] = ["grok-build"] + dump_yaml(manifest, manifest_path) + assert all(check["passed"] for check in installed.audit(0).values()) + + database = installed.home / "retained-native.db" + with closing(sqlite3.connect(database)) as connection: + connection.executescript( + "CREATE TABLE workflows(id TEXT PRIMARY KEY, prompt TEXT, enabled INTEGER);" + "INSERT INTO workflows VALUES('retained', 'original host state', 1);" + ) + for suffix in ("-wal", "-shm"): + Path(f"{database}{suffix}").write_bytes(b"retained native sidecar") + monkeypatch.setenv("APM_COPILOT_APP_DB", str(database)) + writer = Mock(side_effect=AssertionError("Historical native writer reached by audit")) + monkeypatch.setattr(CopilotAppWorkflowIntegrator, "integrate", writer) + + lock_path = installed.project / "apm.lock.yaml" + document = load_yaml(lock_path) + owner = document["deployments"][0]["active_owner"] + uri = "copilot-app-db://workflows/retained-host-workflow" + if representation in {"canonical", "both"}: + document["deployments"].append( + { + "kind": "uri", + "target": "copilot-app", + "value": uri, + "runtime": None, + "scope": "project", + "owners": [owner], + "active_owner": owner, + "content_hash": None, + } + ) + if representation in {"legacy", "both"}: + document["dependencies"][0]["deployed_files"].append(uri) + dump_yaml(document, lock_path) + + checks = installed.audit(1) + writer.assert_not_called() + assert all(check["passed"] for name, check in checks.items() if name != "drift"), checks + assert checks["drift"]["passed"] is False + assert "no isolated filesystem scratch replay backend" in checks["drift"]["message"] + + class _KnownInternalLinkReplayGap(AssertionError): """Only the demonstrated false-orphan outcome is an expected failure.""" diff --git a/tests/unit/test_protocol_config_precedence.py b/tests/unit/test_protocol_config_precedence.py index e0202f525c..95ae0dc476 100644 --- a/tests/unit/test_protocol_config_precedence.py +++ b/tests/unit/test_protocol_config_precedence.py @@ -6,11 +6,69 @@ can be validated in isolation. """ +import json import os -from unittest.mock import patch +from pathlib import Path +from unittest.mock import MagicMock, patch import pytest +from tests.utils.artifact_snapshot import ArtifactSnapshot, assert_unchanged + + +@pytest.mark.parametrize( + ("create_config", "stored", "use_env", "expected_pref", "expected_fallback"), + [ + (False, False, False, None, False), + (True, False, False, None, False), + (False, True, False, "ssh", True), + (False, True, True, "https", False), + ], + ids=["read-only-defaults", "install-bootstrap", "read-only-stored", "read-only-env"], +) +def test_downloader_config_bootstrap_preserves_transport_precedence( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + create_config: bool, + stored: bool, + use_env: bool, + expected_pref: str | None, + expected_fallback: bool, +) -> None: + """Skipping initialization changes no configured or environment transport choice.""" + from apm_cli import config + from apm_cli.deps.github_downloader import GitHubPackageDownloader + from apm_cli.deps.transport_selection import ProtocolPreference + + config_path = tmp_path / ".apm/config.json" + monkeypatch.setattr(config, "CONFIG_DIR", str(config_path.parent)) + monkeypatch.setattr(config, "CONFIG_FILE", str(config_path)) + monkeypatch.setattr(config, "_config_cache", None) + for key in ("APM_GIT_PROTOCOL", "APM_ALLOW_PROTOCOL_FALLBACK"): + monkeypatch.delenv(key, raising=False) + if stored: + config_path.parent.mkdir() + config_path.write_text( + json.dumps({"prefer_ssh": True, "allow_protocol_fallback": True}), encoding="utf-8" + ) + if use_env: + monkeypatch.setenv("APM_GIT_PROTOCOL", "https") + monkeypatch.setenv("APM_ALLOW_PROTOCOL_FALLBACK", "0") + before = ArtifactSnapshot.capture(tmp_path) + + if create_config: + downloader = GitHubPackageDownloader(auth_resolver=MagicMock()) + else: + downloader = GitHubPackageDownloader(auth_resolver=MagicMock(), create_config=False) + + assert downloader._protocol_pref is ProtocolPreference.from_str(expected_pref) + assert downloader._allow_fallback is expected_fallback + if create_config: + assert json.loads(config_path.read_text(encoding="utf-8")) == {"default_client": "vscode"} + else: + assert_unchanged(before, ArtifactSnapshot.capture(tmp_path)) + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- From f5064df947468252bbbeb64aa983ffc9a5bf017e Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Sun, 6 Sep 2026 20:07:22 +0200 Subject: [PATCH 15/34] test(cli): preserve JSON guarantees without audit update notices Keep real update-notice stderr assertions for eligible policy commands while asserting that read-only audit never invokes the update helper. Exercise both JSON flag spellings and remove inherited E2E suppression in this focused control. Production source remains identical to 300a24ac7. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/unit/core/test_output_mode.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/tests/unit/core/test_output_mode.py b/tests/unit/core/test_output_mode.py index 2136084065..d037a036d3 100644 --- a/tests/unit/core/test_output_mode.py +++ b/tests/unit/core/test_output_mode.py @@ -12,23 +12,26 @@ @pytest.mark.parametrize( - "args", + ("args", "expects_update"), [ - ["policy", "status", "--output=json"], - ["policy", "status", "-ojson"], - ["audit", "--ci", "--no-drift", "--no-policy", "--format=json"], - ["audit", "--ci", "--no-drift", "--no-policy", "-fjson"], - ["--verbose", "policy", "status", "--output", "json"], + (["policy", "status", "--output=json"], True), + (["policy", "status", "-ojson"], True), + (["audit", "--ci", "--no-drift", "--no-policy", "--format=json"], False), + (["audit", "--ci", "--no-drift", "--no-policy", "-fjson"], False), + (["--verbose", "policy", "status", "--output", "json"], True), ], ) -def test_machine_output_keeps_update_notice_off_stdout(args: list[str]) -> None: +def test_machine_output_keeps_update_notice_off_stdout( + args: list[str], expects_update: bool, monkeypatch: pytest.MonkeyPatch +) -> None: """Every Click spelling must leave stdout as one parseable JSON document.""" from apm_cli.cli import cli + monkeypatch.delenv("APM_E2E_TESTS", raising=False) with ( patch("apm_cli.commands._helpers.is_self_update_enabled", return_value=True), patch("apm_cli.commands._helpers.get_version", return_value="1.0.0"), - patch("apm_cli.commands._helpers.check_for_updates", return_value="2.0.0"), + patch("apm_cli.commands._helpers.check_for_updates", return_value="2.0.0") as check_updates, patch( "apm_cli.commands.policy.discover_policy_with_chain", return_value=PolicyFetchResult(outcome="absent"), @@ -39,7 +42,12 @@ def test_machine_output_keeps_update_notice_off_stdout(args: list[str]) -> None: assert result.exception is None, result.output json.loads(result.stdout) - assert "A new version of APM is available" in result.stderr + if expects_update: + check_updates.assert_called_once_with("1.0.0") + assert "A new version of APM is available" in result.stderr + else: + check_updates.assert_not_called() + assert "A new version of APM is available" not in result.stderr assert "A new version of APM is available" not in result.stdout From 92833cf4407f3a3a0f80b41fdbd089b0eb926a57 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Mon, 7 Sep 2026 06:10:30 +0200 Subject: [PATCH 16/34] fix(copilot): rebuild managed instruction aggregates after uninstall Retain generated aggregate co-owners, reconstruct Git survivor provenance, and rebuild validated dependency and root-local instructions through existing integration and cleanup owners. Preserve unmanaged collisions and surface hash/security refusals. Add authored source-CLI regressions and registered architecture guardrails. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../owners/install-deployment.json | 6 +- ...install_base_integrator_and_contraction.py | 60 +++++ .../checks/install_deployment_analyzers.py | 7 + src/apm_cli/commands/uninstall/engine.py | 73 ++++++ src/apm_cli/core/deployment_ledger.py | 5 + src/apm_cli/core/deployment_state.py | 29 ++- src/apm_cli/install/manifest_reconcile.py | 1 + src/apm_cli/install/phases/lockfile.py | 22 +- .../integration/instruction_integrator.py | 35 ++- src/apm_cli/models/apm_package.py | 5 + .../test_architecture_copilot_aggregate.py | 58 +++++ .../test_architecture_owner_rule_mutations.py | 8 + .../test_copilot_aggregate_uninstall.py | 216 ++++++++++++++++++ .../test_install_copilot_user_instructions.py | 2 +- .../unit/scripts/test_architecture_runner.py | 1 + 15 files changed, 514 insertions(+), 14 deletions(-) create mode 100644 tests/integration/test_architecture_copilot_aggregate.py create mode 100644 tests/integration/test_copilot_aggregate_uninstall.py diff --git a/.apm/architecture/owners/install-deployment.json b/.apm/architecture/owners/install-deployment.json index a189ce069b..8028ed9342 100644 --- a/.apm/architecture/owners/install-deployment.json +++ b/.apm/architecture/owners/install-deployment.json @@ -19,8 +19,8 @@ "id": "deployment-provenance-state", "decision": "Deployment provenance / state", "owner": "deployment_ledger.py", - "selectors": ["src/apm_cli/core/deployment_ledger.py"], - "guards": ["install-deployment-provenance-state"] + "selectors": ["src/apm_cli/core/deployment_ledger.py", "src/apm_cli/core/deployment_state.py"], + "guards": ["install-deployment-provenance-state", "install-deployment-copilot-aggregate"] }, { "id": "target-scoped-deployed-file-contraction", @@ -116,7 +116,7 @@ "id": "file-level-deploy-sync-cleanup", "decision": "File-level deploy / sync / cleanup", "owner": "BaseIntegrator (see integrators.instructions.md)", - "selectors": ["src/apm_cli/integration/base_integrator.py"], + "selectors": ["src/apm_cli/integration/base_integrator.py", "src/apm_cli/integration/instruction_integrator.py"], "guards": ["install-deployment-base-integrator"] }, { diff --git a/scripts/architecture_linter/checks/install_base_integrator_and_contraction.py b/scripts/architecture_linter/checks/install_base_integrator_and_contraction.py index 59e0936ebe..4b1f6fadc1 100644 --- a/scripts/architecture_linter/checks/install_base_integrator_and_contraction.py +++ b/scripts/architecture_linter/checks/install_base_integrator_and_contraction.py @@ -39,6 +39,66 @@ _GUARD_BASE_INTEGRATOR = "install-deployment-base-integrator" +_GUARD_COPILOT_AGGREGATE = "install-deployment-copilot-aggregate" + + +def check_copilot_aggregate(provider: FactsProvider) -> tuple[Violation, ...]: + """Keep aggregate lifecycle wiring at its integration and provenance owners.""" + routes = ( + ( + "src/apm_cli/install/phases/lockfile.py", + "_attach_deployed_files", + {"aggregate_paths", "reconcile_package_claims", "merge_aggregate_records"}, + ), + ( + "src/apm_cli/commands/uninstall/engine.py", + "_sync_integrations_after_uninstall", + { + "aggregate_paths", + "cleanup_snapshot", + "integrate_local_content", + "integrate_package_primitives", + "finalize_install_result", + }, + ), + ( + "src/apm_cli/integration/instruction_integrator.py", + "sync_for_target", + {"aggregate_paths", "remove_stale_deployed_files"}, + ), + ( + "src/apm_cli/models/apm_package.py", + "build_installed_package_info", + {"to_github_url"}, + ), + ) + findings = [] + for path, function, required in routes: + facts, errors = _facts_for(provider, path, _GUARD_COPILOT_AGGREGATE) + findings.extend(errors) + if errors: + continue + definitions = [ + definition for definition in facts.definitions if definition.name == function + ] + calls = { + call.qualname.rsplit(".", 1)[-1] + for call in facts.calls + if any( + definition.line <= call.line <= definition.end_line for definition in definitions + ) + } + missing = required - calls + if missing: + findings.append( + _summary( + _GUARD_COPILOT_AGGREGATE, + path, + f"{function} must route aggregate decisions through: {', '.join(sorted(missing))}", + ) + ) + return tuple(findings) + def _body_has_re(body: Sequence[str], pattern: re.Pattern[str]) -> bool: """Return whether any captured body line matches `pattern`.""" diff --git a/scripts/architecture_linter/checks/install_deployment_analyzers.py b/scripts/architecture_linter/checks/install_deployment_analyzers.py index a08b607da8..2357f42360 100644 --- a/scripts/architecture_linter/checks/install_deployment_analyzers.py +++ b/scripts/architecture_linter/checks/install_deployment_analyzers.py @@ -15,9 +15,11 @@ from scripts.architecture_linter.checks.install_base_integrator_and_contraction import ( _GUARD_BASE_INTEGRATOR, + _GUARD_COPILOT_AGGREGATE, _GUARD_PROVENANCE, _GUARD_TARGET_CONTRACTION, check_base_integrator, + check_copilot_aggregate, check_provenance_state, check_target_file_contraction, ) @@ -74,6 +76,11 @@ from scripts.architecture_linter.models import Rule RULES: tuple[Rule, ...] = ( + _rule( + _GUARD_COPILOT_AGGREGATE, + "Generated Copilot aggregates use canonical ownership, cleanup and source reconstruction.", + check_copilot_aggregate, + ), _rule( _GUARD_PACKAGE_TARGET, "Restriction-only package target authorization has one owner (install/target_filter.py).", diff --git a/src/apm_cli/commands/uninstall/engine.py b/src/apm_cli/commands/uninstall/engine.py index 4349dace8d..df66eca351 100644 --- a/src/apm_cli/commands/uninstall/engine.py +++ b/src/apm_cli/commands/uninstall/engine.py @@ -1126,6 +1126,7 @@ def _sync_integrations_after_uninstall( """ from ...install.services import ( IntegratorBundle, + integrate_local_content, integrate_package_primitives, ) from ...install.target_filter import resolve_effective_package_targets @@ -1140,6 +1141,21 @@ def _sync_integrations_after_uninstall( _resolved_targets = resolve_targets( project_root, user_scope=user_scope, explicit_target=_explicit ) + from ...core.deployment_ledger import DeploymentLedgerCodec + from ...integration.instruction_integrator import InstructionIntegrator + + aggregate_paths = InstructionIntegrator.aggregate_paths(_resolved_targets) + aggregate_rebuilds = aggregate_paths.intersection(all_deployed_files) + cleanup_hashes = dict(deployed_file_hashes or {}) + if lockfile is not None and aggregate_paths: + # Include surviving claims: older installations recorded only the + # last contributor, so the selected dependency can have no path. + aggregate_snapshot = DeploymentLedgerCodec.cleanup_snapshot( + lockfile, {".", *lockfile.dependencies} + ) + aggregate_rebuilds |= aggregate_paths.intersection(aggregate_snapshot.paths) + cleanup_hashes.update(aggregate_snapshot.hashes) + all_deployed_files.update(aggregate_rebuilds) require_valid_survivors = bool(all_deployed_files) or _native_hook_state_exists( project_root, _resolved_targets, @@ -1232,13 +1248,28 @@ def _sync_integrations_after_uninstall( if _buckets is not None: _bucket_key = BaseIntegrator.partition_bucket_key(_prim_name, _target.name) _managed_subset = _buckets.get(_bucket_key, set()) + aggregate_sync = _prim_name == "instructions" and bool( + InstructionIntegrator.aggregate_paths([_target]) + ) + sync_options = ( + {"managed_file_hashes": cleanup_hashes, "diagnostics": logger.diagnostics} + if aggregate_sync + else {} + ) result = _integrators[_prim_name].sync_for_target( _target, apm_package, project_root, managed_files=_managed_subset, + **sync_options, ) counts[_entry.counter_key] += result.get("files_removed", 0) + if aggregate_sync and result.get("errors", 0): + logger.render_summary() + raise RuntimeError( + "Instruction aggregate cleanup was refused. " + "Restore the managed file or repair its path, then run 'apm install'." + ) # Skills (multi-target, handled by SkillIntegrator) # Check both target root_dir and deploy_root for skill directories @@ -1377,6 +1408,7 @@ def _sync_integrations_after_uninstall( _rebuild_scope = InstallScope.USER if user_scope else InstallScope.PROJECT _allow_executables = getattr(apm_package, "allow_executables", None) reintegration_diagnostics = DiagnosticCollector() + reintegration_errors = 0 for dep_ref, pkg_info, authorized_targets in target_survivor_plan: dep_key = dep_ref.get_unique_key() deployed_files = package_deployed_files.setdefault(dep_key, []) @@ -1399,6 +1431,7 @@ def _sync_integrations_after_uninstall( ) deployed_files.extend(integration_result["deployed_files"]) except Exception as exc: + reintegration_errors += 1 pkg_id = _dependency_public_label(dep_ref) logger.warning( f"Best-effort re-integration skipped for {pkg_id}. " @@ -1408,7 +1441,47 @@ def _sync_integrations_after_uninstall( f" Re-integration error: {_reintegration_error_detail(dep_ref, exc)}" ) + if aggregate_rebuilds: + from dataclasses import replace + + # Rebuild only root instructions, not unrelated first-party primitives. + # Root sources come from the deployment root at user scope, exactly as + # install does; never adopt arbitrary text from the generated output. + root_targets = [ + replace(target, primitives={"instructions": target.primitives["instructions"]}) + for target in _resolved_targets + if InstructionIntegrator.aggregate_paths([target]).intersection(aggregate_rebuilds) + ] + root_result = integrate_local_content( + project_root, + targets=root_targets, + prompt_integrator=_integrator_bundle.prompt, + agent_integrator=_integrator_bundle.agent, + skill_integrator=_integrator_bundle.skill, + instruction_integrator=_integrator_bundle.instruction, + command_integrator=_integrator_bundle.command, + hook_integrator=_integrator_bundle.hook, + force=False, + managed_files=None, + diagnostics=reintegration_diagnostics, + logger=logger, + scope=_rebuild_scope, + ) + package_deployed_files["."] = root_result["deployed_files"] + reintegration_diagnostics.render_summary() + if aggregate_rebuilds: + from ...install.outcome import finalize_install_result + from ...models.results import InstallResult + + rebuild_result = finalize_install_result( + InstallResult(diagnostics=reintegration_diagnostics), force=False + ) + if reintegration_errors or rebuild_result.exit_code: + raise RuntimeError( + "Instruction aggregate rebuild failed. " + "Resolve the reported source errors, then run 'apm install'." + ) return IntegrationCleanupOutcome( counts=counts, deployed_files=package_deployed_files, diff --git a/src/apm_cli/core/deployment_ledger.py b/src/apm_cli/core/deployment_ledger.py index 9f6d00d139..eb6eb8ba41 100644 --- a/src/apm_cli/core/deployment_ledger.py +++ b/src/apm_cli/core/deployment_ledger.py @@ -115,6 +115,8 @@ def cleanup_snapshot( if owner in selected for path in dependency.deployed_files } + if "." in selected: + paths.update(lockfile.local_deployed_files) hashes: dict[str, str] = {} for record in DeploymentLedgerCodec.from_lockfile(lockfile).records.values(): if ( @@ -128,6 +130,9 @@ def cleanup_snapshot( continue for path, content_hash in dependency.deployed_file_hashes.items(): hashes.setdefault(path, content_hash) + if "." in selected: + for path, content_hash in lockfile.local_deployed_file_hashes.items(): + hashes.setdefault(path, content_hash) return DeploymentCleanupSnapshot(paths=frozenset(paths), hashes=hashes) @staticmethod diff --git a/src/apm_cli/core/deployment_state.py b/src/apm_cli/core/deployment_state.py index 38e3002091..9fde7705df 100644 --- a/src/apm_cli/core/deployment_state.py +++ b/src/apm_cli/core/deployment_state.py @@ -168,6 +168,7 @@ def reconcile_package_claims( current_claims: Mapping[str, Iterable[str]], prior_files: Mapping[str, Iterable[str]], prior_hashes: Mapping[str, Mapping[str, str]], + shared_paths: frozenset[str] = frozenset(), ) -> dict[str, PackageDeploymentClaims]: """Resolve legacy last-writer claims and prior carry-forward eligibility.""" normalized_current = {owner: tuple(paths) for owner, paths in current_claims.items()} @@ -179,10 +180,14 @@ def reconcile_package_claims( claims: dict[str, PackageDeploymentClaims] = {} for owner in package_keys: current = tuple( - path for path in normalized_current.get(owner, ()) if last_owner[path] == owner + path + for path in normalized_current.get(owner, ()) + if path in shared_paths or last_owner[path] == owner ) eligible_prior = tuple( - path for path in prior_files.get(owner, ()) if last_owner.get(path, owner) == owner + path + for path in prior_files.get(owner, ()) + if path in shared_paths or last_owner.get(path, owner) == owner ) eligible_prior_set = set(eligible_prior) claims[owner] = PackageDeploymentClaims( @@ -196,6 +201,26 @@ def reconcile_package_claims( ) return claims + @staticmethod + def merge_aggregate_records( + previous: dict[str, DeploymentRecord], + current: Mapping[str, DeploymentRecord], + shared_paths: frozenset[str], + ) -> dict[str, DeploymentRecord]: + """Accumulate current-run aggregate proofs without changing ordinary handoffs.""" + records = previous + for key, record in current.items(): + prior = records.get(key) + if prior is not None and record.locator.value in shared_paths: + record = DeploymentRecord( + locator=record.locator, + owners=tuple(dict.fromkeys((*prior.owners, *record.owners))), + active_owner=record.active_owner, + content_hash=record.content_hash, + ) + records[key] = record + return records + def reconcile( self, previous: DeploymentLedger, diff --git a/src/apm_cli/install/manifest_reconcile.py b/src/apm_cli/install/manifest_reconcile.py index 4cefc0cbfe..47dc799372 100644 --- a/src/apm_cli/install/manifest_reconcile.py +++ b/src/apm_cli/install/manifest_reconcile.py @@ -706,6 +706,7 @@ def reconcile_target_deployed_files( if dep_key not in (dependency_keys or set()) for path in dependency.deployed_files } + survivor_files.update(lockfile.local_deployed_files) desired_owners = ( DeploymentLedgerCodec.valid_owner_keys( lockfile, diff --git a/src/apm_cli/install/phases/lockfile.py b/src/apm_cli/install/phases/lockfile.py index d0b6a0d376..8783941775 100644 --- a/src/apm_cli/install/phases/lockfile.py +++ b/src/apm_cli/install/phases/lockfile.py @@ -232,12 +232,22 @@ def _attach_deployed_files(self, lockfile: LockFile) -> None: previous.deployed_file_hashes if previous is not None else {} ) from apm_cli.core.deployment_state import DeploymentReconciler - + from apm_cli.integration.instruction_integrator import InstructionIntegrator + + shared_paths = InstructionIntegrator.aggregate_paths(self.ctx.targets) + current_claims = dict(self.ctx.package_deployed_files) + local_aggregates = shared_paths.intersection(getattr(self.ctx, "local_deployed_files", ())) + if local_aggregates: + current_claims["."] = sorted(local_aggregates) + package_keys = list(lockfile.dependencies) + if local_aggregates and "." not in package_keys: + package_keys.append(".") package_claims = DeploymentReconciler.reconcile_package_claims( - package_keys=lockfile.dependencies, - current_claims=self.ctx.package_deployed_files, + package_keys=package_keys, + current_claims=current_claims, prior_files=prior_files_by_package, prior_hashes=prior_hashes_by_package, + shared_paths=shared_paths, ) declared = declared_target_profiles(self.ctx) diagnostics = getattr(self.ctx, "diagnostics", None) @@ -257,7 +267,7 @@ def _attach_deployed_files(self, lockfile: LockFile) -> None: lockfile_only = getattr(self.ctx, "lockfile_only", False) canonical_records = {} ghost_count = 0 - for dep_key in lockfile.dependencies: + for dep_key in package_keys: claim = package_claims[dep_key] current = list(claim.current_files) retained_hashes = cleanup_retained.get(dep_key, {}) @@ -302,7 +312,9 @@ def _log_ghost_drop(path: str, package_key: str = dep_key) -> None: # leave deployed_files untouched so the whole-dep # _merge_existing path can preserve it intact. continue - canonical_records.update(ledger.records) + canonical_records = DeploymentReconciler.merge_aggregate_records( + canonical_records, ledger.records, shared_paths + ) from apm_cli.core.deployment_ledger import DeploymentLedgerCodec DeploymentLedgerCodec.apply_to_lockfile( diff --git a/src/apm_cli/integration/instruction_integrator.py b/src/apm_cli/integration/instruction_integrator.py index 497f7ad38f..89ed63ad6e 100644 --- a/src/apm_cli/integration/instruction_integrator.py +++ b/src/apm_cli/integration/instruction_integrator.py @@ -29,6 +29,7 @@ if TYPE_CHECKING: from apm_cli.install.deployable_source_plan import DeployableSourcePlan from apm_cli.integration.targets import TargetProfile + from apm_cli.utils.diagnostics import DiagnosticCollector @dataclass(frozen=True) @@ -494,6 +495,9 @@ def sync_for_target( apm_package, project_root: Path, managed_files: set[str] | None = None, + *, + managed_file_hashes: dict[str, str] | None = None, + diagnostics: DiagnosticCollector | None = None, ) -> dict[str, int]: """Remove APM-managed instruction files for a single *target*.""" mapping = target.primitives.get("instructions") @@ -501,9 +505,24 @@ def sync_for_target( return {"files_removed": 0, "errors": 0} effective_root = mapping.deploy_root or target.root_dir if mapping.format_id == "copilot_user_instructions": - prefix = f"{effective_root}/copilot-instructions.md" - legacy_pattern = None - legacy_dir = None + from apm_cli.integration.cleanup import remove_stale_deployed_files + from apm_cli.utils.diagnostics import DiagnosticCollector + + # Generated aggregates are whole-file rebuilds, but only after + # the cleanup owner's containment and recorded-hash checks pass. + cleanup = remove_stale_deployed_files( + project_root=project_root, + stale_paths=self.aggregate_paths([target]).intersection(managed_files or ()), + recorded_hashes=managed_file_hashes or {}, + diagnostics=diagnostics or DiagnosticCollector(), + dep_key="", + targets=[target], + user_scope=True, + ) + return { + "files_removed": len(cleanup.deleted), + "errors": len(cleanup.retained), + } else: prefix = f"{effective_root}/{mapping.subdir}/" legacy_dir = project_root / effective_root / mapping.subdir @@ -535,6 +554,16 @@ def sync_for_target( # Copilot user-scope concat support # ------------------------------------------------------------------ + @staticmethod + def aggregate_paths(targets: list[TargetProfile]) -> frozenset[str]: + """Return generated instruction paths whose contributions share ownership.""" + return frozenset( + (Path(mapping.deploy_root or target.root_dir) / "copilot-instructions.md").as_posix() + for target in targets + if (mapping := target.primitives.get("instructions")) is not None + and mapping.format_id == "copilot_user_instructions" + ) + # Sentinel line written as the first line of every APM-managed # copilot-instructions.md. Its presence distinguishes the file from # a user-authored one, enabling multi-package accumulation without diff --git a/src/apm_cli/models/apm_package.py b/src/apm_cli/models/apm_package.py index 833d570960..a8a3d62171 100644 --- a/src/apm_cli/models/apm_package.py +++ b/src/apm_cli/models/apm_package.py @@ -867,6 +867,11 @@ def build_installed_package_info( if not package: return None + if dep_ref.source in (None, "git"): + # Validation reconstructs authored metadata, not acquisition provenance. + # Reuse the identified Git source when rebuilding deployed sections. + package.source = dep_ref.to_github_url() + return PackageInfo( package=package, install_path=install_path, diff --git a/tests/integration/test_architecture_copilot_aggregate.py b/tests/integration/test_architecture_copilot_aggregate.py new file mode 100644 index 0000000000..7614fd7f9c --- /dev/null +++ b/tests/integration/test_architecture_copilot_aggregate.py @@ -0,0 +1,58 @@ +"""Executable boundary checks for generated-aggregate lifecycle routing.""" + +from pathlib import Path + +import pytest + +from scripts.architecture_linter.runner import run_selected_rules + +pytestmark = pytest.mark.component + +ROOT = Path(__file__).resolve().parents[2] +RULE = "install-deployment-copilot-aggregate" + + +def test_copilot_aggregate_routes_are_registered_and_satisfied() -> None: + """The stable architecture entrypoint accepts the production routes.""" + report = run_selected_rules(ROOT, (RULE,)) + assert report.failures == () + assert report.violations == () + + +@pytest.mark.parametrize( + ("path", "old", "new"), + [ + ( + "src/apm_cli/install/phases/lockfile.py", + "DeploymentReconciler.merge_aggregate_records(", + "parallel_merge(", + ), + ( + "src/apm_cli/commands/uninstall/engine.py", + "root_result = integrate_local_content(", + "root_result = unscanned_local_content(", + ), + ( + "src/apm_cli/integration/instruction_integrator.py", + "cleanup = remove_stale_deployed_files(", + "cleanup = unsafe_delete(", + ), + ( + "src/apm_cli/models/apm_package.py", + "package.source = dep_ref.to_github_url()", + "package.source = None", + ), + ( + "src/apm_cli/commands/uninstall/engine.py", + "rebuild_result = finalize_install_result(", + "rebuild_result = parallel_outcome(", + ), + ], +) +def test_copilot_aggregate_guard_rejects_bypassed_owner(path: str, old: str, new: str) -> None: + """Each centralization stays guarded against a sibling or missing authority.""" + source = (ROOT / path).read_text(encoding="utf-8") + assert source.count(old) == 1 + report = run_selected_rules(ROOT, (RULE,), source_overrides={path: source.replace(old, new)}) + assert report.failures == () + assert any(violation.rule_id == RULE 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 1e6497774c..fba1371ee2 100644 --- a/tests/integration/test_architecture_owner_rule_mutations.py +++ b/tests/integration/test_architecture_owner_rule_mutations.py @@ -75,6 +75,14 @@ class MutationCase: MUTATIONS: tuple[MutationCase, ...] = ( + MutationCase( + guard_id="install-deployment-copilot-aggregate", + rule_id="install-deployment-copilot-aggregate", + path="src/apm_cli/install/phases/lockfile.py", + old="DeploymentReconciler.merge_aggregate_records(", + new="parallel_aggregate_merge(", + intent="Aggregate ledger accumulation bypasses the canonical ownership reconciler.", + ), MutationCase( guard_id="contracts-tests-taxonomy-classification", rule_id="contracts-tests-taxonomy-classification", diff --git a/tests/integration/test_copilot_aggregate_uninstall.py b/tests/integration/test_copilot_aggregate_uninstall.py new file mode 100644 index 0000000000..7a07304603 --- /dev/null +++ b/tests/integration/test_copilot_aggregate_uninstall.py @@ -0,0 +1,216 @@ +"""Source-backed, installed-CLI proofs for the generated Copilot aggregate.""" + +from __future__ import annotations + +import hashlib +import os +import re +from pathlib import Path +from urllib.parse import urlparse + +import pytest + +from apm_cli.deps.lockfile import LockFile +from apm_cli.models.dependency.selection import parse_dependency_entry +from apm_cli.utils.yaml_io import dump_yaml, load_yaml +from tests.utils.apm_lifecycle_runner import ApmLifecycleRunner +from tests.utils.isolated_apm_environment import IsolatedApmEnvironment +from tests.utils.local_git_repository import LocalGitRepositoryFactory +from tests.utils.local_package import LocalPackageFactory + +pytestmark = [ + pytest.mark.e2e, + pytest.mark.requires_apm_binary, + pytest.mark.lifecycle_smoke, + pytest.mark.lifecycle_merge_group, +] + +_HEADER = "" +_NOTES = b"# Independent notes\nDo not attribute or modify these user bytes.\n" +_ROOT_BODY = "# Root contribution\nKeep the actual root-local instruction.\n" + + +@pytest.mark.parametrize( + "obligation", + [ + "initial-owners", + "removed-section", + "survivor-identity", + "survivor-state", + "collision", + "root", + "edited", + "unsafe-survivor", + ], +) +def test_generated_copilot_aggregate_lifecycle( + tmp_path: Path, apm_binary_path: Path, obligation: str +) -> None: + """Keep owner, cleanup and identity failures independent, never xfailed.""" + isolated = IsolatedApmEnvironment.create(tmp_path / "scenario", base_env=os.environ) + factory = LocalPackageFactory(isolated.package_root) + repositories = LocalGitRepositoryFactory( + isolated.repository_root, env=isolated.subprocess_env() + ) + declarations = [] + sources = {} + refs = {} + commits = {} + bodies = {} + rewrites = [] + for name in ("primary", "survivor"): + package = factory.create(name, targets=["copilot"]) + bodies[name] = f"# {name.title()} instructions\nAuthored {name} contribution only.\n" + instruction = factory.add_instruction( + package, name, "---\napplyTo: '**'\n---\n\n" + bodies[name] + ) + repository = repositories.create(name, source_tree=package.root) + commit = repositories.commit(repository, message=f"Author {name} instructions") + remote = f"https://gitlab.com/apm-aggregate-fixture/{name}.git" + declaration = {"git": remote, "type": "gitlab", "ref": commit.sha, "alias": name} + declarations.append(declaration) + refs[name] = parse_dependency_entry(declaration) + commits[name] = commit.sha + sources[name] = { + "apm.yml": package.manifest_path.read_bytes(), + f".apm/instructions/{name}.instructions.md": instruction.read_bytes(), + } + rewrites.append((repository, remote)) + environment = repositories.url_rewrite_subprocess_env_many(rewrites) + consumer = LocalPackageFactory(isolated.work_root).create("consumer") + manifest = { + "name": "consumer", + "version": "0.1.0", + "targets": ["copilot"], + "dependencies": {"apm": declarations}, + } + dump_yaml(manifest, isolated.config_root / "apm.yml") + if obligation == "root": + root_instruction = isolated.home / ".apm/instructions/root.instructions.md" + root_instruction.parent.mkdir(parents=True) + root_instruction.write_text(_ROOT_BODY, encoding="utf-8") + + aggregate = isolated.home / ".copilot/copilot-instructions.md" + notes = aggregate.with_name("lifecycle-user-notes.md") + notes.parent.mkdir() + notes.write_bytes(_NOTES) + assert not aggregate.exists(), "Supported generated input must start absent" + collision = b"# User instructions\nNever adopt this headerless content.\n" + if obligation == "collision": + aggregate.write_bytes(collision) + runner = ApmLifecycleRunner([str(apm_binary_path)]) + + def command(*args: str, expected_returncode: int = 0) -> None: + result = runner.run(args, scenario_id=obligation, cwd=consumer.root, env=environment) + print("COMMAND", result.command, "EXIT", result.returncode) + print(result.stdout, result.stderr) + assert result.returncode == expected_returncode, (result.stdout, result.stderr) + assert notes.read_bytes() == _NOTES, "Unowned neighboring notes changed" + + command( + "install", "--global", "--target", "copilot", "--no-policy", "--parallel-downloads", "0" + ) + lock = LockFile.read(isolated.config_root / "apm.lock.yaml") + assert lock is not None + expected_keys = {refs[name].get_unique_key() for name in refs} + assert {dep.get_unique_key() for dep in lock.get_package_dependencies()} == expected_keys + for name, ref in refs.items(): + locked = lock.dependencies[ref.get_unique_key()] + assert locked.resolved_commit == commits[name] + assert locked.to_dependency_ref().get_unique_key() == ref.get_unique_key() + for relative, content in sources[name].items(): + assert ( + ref.get_install_path(isolated.config_root / "apm_modules") / relative + ).read_bytes() == content + assert load_yaml(isolated.config_root / "apm.yml") == manifest + initial = aggregate.read_text(encoding="utf-8") + print("INITIAL AGGREGATE", initial) + print("INITIAL LEDGER", lock.deployment_ledger) + if obligation == "collision": + assert aggregate.read_bytes() == collision + assert not lock.deployment_ledger.records, "Unmanaged output acquired an ownership claim" + return + assert initial.startswith(_HEADER) + for body in bodies.values(): + assert initial.count(body.strip()) == 1 + if obligation == "initial-owners": + records = list(lock.deployment_ledger.records.values()) + assert len(records) == 1 + assert set(records[0].owners) == expected_keys, "Initial aggregate lost an authored owner" + assert records[0].active_owner in expected_keys + return + if obligation == "root": + assert initial.count(_ROOT_BODY.strip()) == 1 + if obligation == "edited": + # Negative cleanup control, not a promise of editable generated regions. + edited = aggregate.read_bytes() + b"\nChanged after install; refuse hash mismatch.\n" + aggregate.write_bytes(edited) + command("uninstall", declarations[0]["git"], "--global", expected_returncode=1) + assert aggregate.read_bytes() == edited, "Hash-refused aggregate was overwritten" + return + if obligation == "unsafe-survivor": + installed_instruction = ( + refs["survivor"].get_install_path(isolated.config_root / "apm_modules") + / ".apm/instructions/survivor.instructions.md" + ) + installed_instruction.write_text("# Unsafe\nHidden \u202e instruction.\n", encoding="utf-8") + command("uninstall", declarations[0]["git"], "--global", expected_returncode=1) + assert not aggregate.exists(), "Rejected source was deployed during aggregate rebuild" + return + + command("uninstall", declarations[0]["git"], "--global") + remaining = aggregate.read_text(encoding="utf-8") + print("POST-UNINSTALL AGGREGATE", remaining) + if obligation == "removed-section": + assert bodies["primary"].strip() not in remaining, "Removed-owner body survived" + assert "primary" not in remaining, "Removed-owner provenance survived" + elif obligation == "survivor-identity": + identities = re.findall(r"", remaining) + assert [urlparse(identity) for identity in identities] == [ + urlparse(refs["survivor"].to_github_url()) + ], "Survivor identity must be resolved and unique" + assert "apm:source:unknown" not in remaining + elif obligation == "root": + assert remaining.count(_ROOT_BODY.strip()) == 1, "Actual root-local contribution lost" + assert root_instruction.read_text(encoding="utf-8") == _ROOT_BODY + command("uninstall", declarations[1]["git"], "--global") + assert aggregate.read_text(encoding="utf-8") == ( + f"{_HEADER}\n\n{_ROOT_BODY.strip()}\n\n" + ) + root_lock = LockFile.read(isolated.config_root / "apm.lock.yaml") + assert root_lock is not None + assert root_lock.get_package_dependencies() == [] + assert [record.owners for record in root_lock.deployment_ledger.records.values()] == [ + (".",) + ] + else: + survivor = refs["survivor"] + lock = LockFile.read(isolated.config_root / "apm.lock.yaml") + assert lock is not None + assert set(lock.dependencies) == {survivor.get_unique_key()}, "Survivor graph/key changed" + locked = lock.dependencies[survivor.get_unique_key()] + assert locked.name == "survivor" + assert locked.resolved_commit == commits["survivor"], "Survivor commit changed" + assert locked.to_dependency_ref().get_unique_key() == survivor.get_unique_key() + assert ( + load_yaml(isolated.config_root / "apm.yml")["dependencies"]["apm"] == declarations[1:] + ) + assert not refs["primary"].get_install_path(isolated.config_root / "apm_modules").exists() + for relative, content in sources["survivor"].items(): + assert ( + survivor.get_install_path(isolated.config_root / "apm_modules") / relative + ).read_bytes() == content + assert remaining.count(bodies["survivor"].strip()) == 1, "Survivor native content changed" + assert remaining == ( + f"{_HEADER}\n\n" + f"{bodies['survivor'].strip()}\n\n" + ) + records = list(lock.deployment_ledger.records.values()) + assert len(records) == 1 + assert records[0].owners == (survivor.get_unique_key(),) + assert records[0].active_owner == survivor.get_unique_key() + assert ( + records[0].content_hash + == "sha256:" + hashlib.sha256(aggregate.read_bytes()).hexdigest() + ) + assert notes.read_bytes() == _NOTES diff --git a/tests/unit/install/test_install_copilot_user_instructions.py b/tests/unit/install/test_install_copilot_user_instructions.py index 21ce876998..13284fbfc0 100644 --- a/tests/unit/install/test_install_copilot_user_instructions.py +++ b/tests/unit/install/test_install_copilot_user_instructions.py @@ -262,4 +262,4 @@ def test_project_scope_unaffected(self): assert result.files_integrated == 1 deployed = result.target_paths[0] assert deployed.name == "coding.instructions.md" - assert ".copilot" not in str(deployed) + assert deployed.relative_to(project_root).parent == Path(".github/instructions") diff --git a/tests/unit/scripts/test_architecture_runner.py b/tests/unit/scripts/test_architecture_runner.py index 7ca8fb98b8..96d9747606 100644 --- a/tests/unit/scripts/test_architecture_runner.py +++ b/tests/unit/scripts/test_architecture_runner.py @@ -619,6 +619,7 @@ def exiting_import( install-deployment-bundle-native-layout install-deployment-base-integrator install-deployment-cached-claude-skill-metadata +install-deployment-copilot-aggregate install-deployment-dependency-winner-selection install-deployment-deployment-frame-projection install-deployment-executable-trust-context From 216aca2daa65673268a819141787f1d01f450478 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Mon, 7 Sep 2026 07:45:24 +0200 Subject: [PATCH 17/34] fix(lifecycle): fold aggregate, audit, and recovery review findings Preserve confirmed aggregate removal and survivor identity through canonical owners; make refusal recovery sufficient and scoped, bound attachment and audit comparison work, and keep Windows audit configuration reads noncreating. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- .../content/docs/reference/targets-matrix.md | 18 ++ .../.apm/skills/apm-usage/commands.md | 7 + ...install_base_integrator_and_contraction.py | 79 ++++++++ .../checks/transport_auth_platform.py | 26 +++ src/apm_cli/commands/uninstall/cli.py | 35 +++- src/apm_cli/commands/uninstall/engine.py | 31 ++- .../commands/uninstall/lockfile_state.py | 15 ++ src/apm_cli/config.py | 14 +- src/apm_cli/core/deployment_ledger.py | 11 + src/apm_cli/core/deployment_state.py | 32 +-- src/apm_cli/deps/git_auth_env.py | 4 +- src/apm_cli/install/drift.py | 42 ++-- src/apm_cli/install/helpers/security_scan.py | 10 +- src/apm_cli/install/phases/lockfile.py | 54 ++++- src/apm_cli/install/services.py | 6 +- src/apm_cli/install/sources.py | 3 + .../integration/instruction_integrator.py | 3 +- src/apm_cli/models/apm_package.py | 11 +- .../test_architecture_copilot_aggregate.py | 25 +++ .../test_architecture_owner_rule_mutations.py | 41 ++++ .../test_copilot_aggregate_uninstall.py | 191 +++++++++++++++--- .../test_lockfile_cross_package_reconcile.py | 116 +++++++++++ tests/unit/install/test_audit_target_roots.py | 141 +++++++++++++ tests/unit/test_protocol_config_precedence.py | 67 +++++- tests/unit/test_uninstall_engine_helpers.py | 48 +++++ 26 files changed, 934 insertions(+), 98 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50452bd873..98166f19b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Global installs anchor local children to established declaring sources without treating disguised remote names as local, and audit replays current target intent without modifying live configuration or native state. The corrective draft `docs/src/content/docs/specs/openapm-v0.2.md` versions these contracts separately; migration: pin assessments to exact `v0.2.0`, while the previous minor remains available indefinitely. Publication and ratification are pending. (#2820) +- Global installs anchor local children to established declaring sources without treating disguised remote names as local, and audit replays current target intent without modifying live configuration or native state; `apm uninstall -g` removes departed generated Copilot sections and rebuilds eligible contributions. The corrective draft `docs/src/content/docs/specs/openapm-v0.2.md` versions these contracts separately; migration: pin assessments to exact `v0.2.0`, while the previous minor remains available indefinitely. Publication and ratification are pending. (#2820) - Unix installation defaults to user-local directories without profile edits or implicit `sudo`. Existing destinations are preserved, but ordinary users cannot replace root-owned or package-managed installs; ask the original administrator or package manager to update or uninstall before migrating. See [Unix install ownership and migration](https://microsoft.github.io/apm/getting-started/installation/#unix-install-ownership-and-migration). (#2844) ### Changed diff --git a/docs/src/content/docs/reference/targets-matrix.md b/docs/src/content/docs/reference/targets-matrix.md index ccc077370b..0f63b7e612 100644 --- a/docs/src/content/docs/reference/targets-matrix.md +++ b/docs/src/content/docs/reference/targets-matrix.md @@ -128,6 +128,24 @@ GitHub Copilot (CLI and IDE). at user scope). User-scope deploys land under `~/.copilot/`, not `~/.github/`; hook script commands are written as absolute paths so Copilot CLI can invoke them from any working directory. + + `apm uninstall -g PKG` removes the selected package's generated sections + and rebuilds from surviving packages eligible for the resolved Copilot + target and root instructions in `~/.apm/instructions/`, recording those + contributors' ownership. If no eligible instruction content remains, successful cleanup and + lockfile persistence leave no managed aggregate or ownership records + for it. By default, APM + leaves an unmanaged file at that output path unchanged without taking + ownership. Write root `*.instructions.md` files in `~/.apm/instructions/`, + not in the aggregate. + + If aggregate cleanup or rebuilding fails, package and declaration removal + has already completed. For a hash refusal, back up and move the edited + `~/.copilot/copilot-instructions.md` out of its managed path, then run + `apm install --global`. Keep the backup to port wanted edits manually to + `~/.apm/instructions/`; do not restore the old aggregate. For security + errors, fix the reported unsafe source before retrying + `apm install --global`. - **Global compile.** `apm compile -g` can also render global instructions to `~/.copilot/AGENTS.md` for root-context readers that honor `AGENTS.md`. diff --git a/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md index eeb9b2e14a..d548ee2a2a 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/commands.md +++ b/packages/apm-guide/.apm/skills/apm-usage/commands.md @@ -41,6 +41,13 @@ declaring package's original source directory; see [local-path anchoring](depend | `apm cache prune` | Remove Git checkout SHA groups outside the retention window; all full and sparse variants share recency and eviction | `--days N` (default `30`) | | `apm deps update [PKGS...]` | Deprecated -- use `apm update` instead (now a strict superset). Update specific packages | `--verbose`, `--force`, `--target` (comma-separated), `--parallel-downloads N`, `-g/--global`, `--legacy-skill-paths` | +`apm uninstall -g PKG` rebuilds APM-managed +`~/.copilot/copilot-instructions.md` from eligible surviving-package and +root contributions. If no eligible instruction content remains, +successful cleanup and lockfile persistence remove the managed aggregate +and clear its ownership records. By default, APM leaves an unmanaged file +unchanged without taking ownership. + For `apm install -g --mcp`, mixed target selections warn and skip workspace-only runtimes. If no selected target supports user scope, the command exits `2` before creating or changing the user manifest, lockfile, or runtime diff --git a/scripts/architecture_linter/checks/install_base_integrator_and_contraction.py b/scripts/architecture_linter/checks/install_base_integrator_and_contraction.py index 4b1f6fadc1..9c5213e8c9 100644 --- a/scripts/architecture_linter/checks/install_base_integrator_and_contraction.py +++ b/scripts/architecture_linter/checks/install_base_integrator_and_contraction.py @@ -50,6 +50,11 @@ def check_copilot_aggregate(provider: FactsProvider) -> tuple[Violation, ...]: "_attach_deployed_files", {"aggregate_paths", "reconcile_package_claims", "merge_aggregate_records"}, ), + ( + "src/apm_cli/install/phases/lockfile.py", + "_preserve_existing_local_state", + {"aggregate_paths", "replace_legacy_owner"}, + ), ( "src/apm_cli/commands/uninstall/engine.py", "_sync_integrations_after_uninstall", @@ -69,8 +74,28 @@ def check_copilot_aggregate(provider: FactsProvider) -> tuple[Violation, ...]: ( "src/apm_cli/models/apm_package.py", "build_installed_package_info", + {"restore_installed_package_source"}, + ), + ( + "src/apm_cli/models/apm_package.py", + "restore_installed_package_source", {"to_github_url"}, ), + ( + "src/apm_cli/install/sources.py", + "acquire", + {"restore_installed_package_source"}, + ), + ( + "src/apm_cli/commands/uninstall/cli.py", + "uninstall", + {"reconcile_uninstall_deployment_state"}, + ), + ( + "src/apm_cli/commands/uninstall/lockfile_state.py", + "reconcile_uninstall_deployment_state", + {"MaterializationResult", "reconcile", "apply_to_lockfile"}, + ), ) findings = [] for path, function, required in routes: @@ -97,6 +122,60 @@ def check_copilot_aggregate(provider: FactsProvider) -> tuple[Violation, ...]: f"{function} must route aggregate decisions through: {', '.join(sorted(missing))}", ) ) + # Successful cleanup facts must cross both adapters, not merely coexist + # with calls to the owner. Use the shared index (no second parse/walk). + forwardings = ( + ( + "src/apm_cli/commands/uninstall/engine.py", + "IntegrationCleanupOutcome", + "removed_aggregate_paths", + ), + ( + "src/apm_cli/commands/uninstall/cli.py", + "reconcile_uninstall_deployment_state", + "integration_cleanup", + ), + ) + for path, callee, source in forwardings: + index = provider.tree_index(path) + if index is None or index.root is None: + continue + forwarded = any( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == callee + and any( + keyword.arg == "removed_paths" and source in _loaded_names(index, keyword.value) + for keyword in node.keywords + ) + for node in index.walk(index.root) + ) + if not forwarded: + findings.append( + _summary( + _GUARD_COPILOT_AGGREGATE, + path, + f"{callee} must forward confirmed removed_paths from {source}", + ) + ) + path = "src/apm_cli/commands/uninstall/lockfile_state.py" + index = provider.tree_index(path) + if index is not None and index.root is not None: + removed_proof = any( + isinstance(node, ast.Attribute) + and node.attr == "REMOVED" + and isinstance(node.value, ast.Name) + and node.value.id == "MaterializationStatus" + for node in index.walk(index.root) + ) + if not removed_proof: + findings.append( + _summary( + _GUARD_COPILOT_AGGREGATE, + path, + "Confirmed cleanup must supply canonical MaterializationStatus.REMOVED evidence", + ) + ) return tuple(findings) diff --git a/scripts/architecture_linter/checks/transport_auth_platform.py b/scripts/architecture_linter/checks/transport_auth_platform.py index d9f8cb702e..a927a9101c 100644 --- a/scripts/architecture_linter/checks/transport_auth_platform.py +++ b/scripts/architecture_linter/checks/transport_auth_platform.py @@ -144,6 +144,32 @@ def _check_host_credential_resolution(provider: FactsProvider) -> tuple[Violatio inv = frozenset(provider.inventory) findings: list[Violation] = [] + # Git credential isolation may write its scratch sentinel, never bootstrap + # user configuration. Keep temp precedence with the configuration owner. + for path, required in ( + ( + "src/apm_cli/deps/git_auth_env.py", + ("get_apm_temp_dir(create_config=False)",), + ), + ( + "src/apm_cli/config.py", + ( + 'return get_config(create=create_config).get("temp_dir")', + "get_temp_dir(create_config=create_config)", + ), + ), + ): + findings.extend( + _require_subs( + provider, + inv, + _RID_HOST_CRED, + path, + required, + "Git sentinel temp lookup must use noncreating configuration-owner reads", + ) + ) + # AC5 -- AuthResolver must scrub inherited Git authorization state. findings.extend( _require_subs( diff --git a/src/apm_cli/commands/uninstall/cli.py b/src/apm_cli/commands/uninstall/cli.py index 5638a8c01a..2c6ba704ef 100644 --- a/src/apm_cli/commands/uninstall/cli.py +++ b/src/apm_cli/commands/uninstall/cli.py @@ -14,6 +14,7 @@ from ...models.apm_package import APMPackage from ...utils.path_security import PathTraversalError from .engine import ( + AggregateIntegrationError, IntegrationCleanupOutcome, LocalSurvivorRefreshError, MCPUninstallCleanupError, @@ -89,11 +90,12 @@ def _sync_integrations_for_manifest( failed_paths=[], error_count=0, ) + recovery_command = "apm install --global" if user_scope else "apm install" try: apm_package = APMPackage.from_apm_yml(manifest_path) except Exception as manifest_err: logger.warning("Integration cleanup did not finish.") - logger.warning("Run 'apm install' to resync remaining integrations.") + logger.warning(f"Run '{recovery_command}' to resync remaining integrations.") logger.verbose_detail( f"Integration cleanup skipped: {type(manifest_err).__name__}: {manifest_err}" ) @@ -114,8 +116,30 @@ def _sync_integrations_for_manifest( None, ) except Exception as sync_err: - logger.warning("Integration cleanup did not finish.") - logger.warning("Run 'apm install' to resync remaining integrations.") + if isinstance(sync_err, AggregateIntegrationError): + from ...utils.diagnostics import printable_ascii_text + + logger.warning(str(sync_err)) + logger.warning( + "Package removal and declaration removal completed; integration cleanup is incomplete." + ) + prefix = "~/" if user_scope else "" + for path in sorted(sync_err.paths): + logger.warning(f"Managed path: {printable_ascii_text(prefix + path)}") + if sync_err.phase == "cleanup": + logger.warning( + "Back up and move the refused generated file out of the managed path " + "before regeneration; do not restore the stale multi-owner file." + ) + source_path = "~/.apm/instructions/" if user_scope else ".apm/instructions/" + logger.warning( + f"Keep genuine authoring in {source_path}. Repair unsafe paths before retrying." + ) + else: + logger.warning("Fix the reported source errors before retrying.") + else: + logger.warning("Integration cleanup did not finish.") + logger.warning(f"Run '{recovery_command}' to resync remaining integrations.") logger.verbose_detail(f"Integration cleanup failed: {type(sync_err).__name__}: {sync_err}") logger.verbose_detail(traceback.format_exc().rstrip()) return default_outcome, sync_err @@ -582,6 +606,11 @@ def uninstall(ctx, packages, dry_run, verbose, global_): deploy_root=deploy_root, all_deployed_files=all_deployed_files, surviving_deployed_files=surviving_deployed_files, + removed_paths=( + integration_cleanup.removed_paths + if integration_cleanup.complete and integration_cleanup_error is None + else frozenset() + ), fully_refreshed_dependency_keys=refreshed_survivor_keys, ) or lockfile_updated diff --git a/src/apm_cli/commands/uninstall/engine.py b/src/apm_cli/commands/uninstall/engine.py index df66eca351..0e4bafb20d 100644 --- a/src/apm_cli/commands/uninstall/engine.py +++ b/src/apm_cli/commands/uninstall/engine.py @@ -1080,6 +1080,7 @@ class IntegrationCleanupOutcome: deployed_files: dict[str, list[str]] failed_paths: list[str] error_count: int + removed_paths: frozenset[str] = frozenset() @property def complete(self) -> bool: @@ -1087,6 +1088,20 @@ def complete(self) -> bool: return self.error_count == 0 +class AggregateIntegrationError(RuntimeError): + """Safe public aggregate failure facts, without raw source exception text.""" + + def __init__(self, phase: str, paths: frozenset[str]) -> None: + self.phase = phase + self.paths = paths + cause = ( + "managed content changed or could not be safely removed" + if phase == "cleanup" + else "source validation or integration failed" + ) + super().__init__(f"Instruction aggregate {phase} failed: {cause}.") + + def _native_hook_state_exists(project_root: Path, targets: list[object]) -> bool: """Return whether cleanup may rewrite a merged native-hook config.""" from ...integration.hook_integrator import _APM_HOOKS_SIDECAR, _MERGE_HOOK_TARGETS @@ -1228,6 +1243,7 @@ def _sync_integrations_after_uninstall( counts = {entry.counter_key: 0 for entry in _dispatch.values()} package_deployed_files: dict[str, list[str]] = {} + removed_aggregate_paths: set[str] = set() # Phase 1: Remove all APM-deployed files # Per-target sync for primitives with sync_for_target @@ -1264,12 +1280,11 @@ def _sync_integrations_after_uninstall( **sync_options, ) counts[_entry.counter_key] += result.get("files_removed", 0) + if aggregate_sync: + removed_aggregate_paths.update(result.get("removed_paths", ())) if aggregate_sync and result.get("errors", 0): logger.render_summary() - raise RuntimeError( - "Instruction aggregate cleanup was refused. " - "Restore the managed file or repair its path, then run 'apm install'." - ) + raise AggregateIntegrationError("cleanup", aggregate_rebuilds) # Skills (multi-target, handled by SkillIntegrator) # Check both target root_dir and deploy_root for skill directories @@ -1428,6 +1443,7 @@ def _sync_integrations_after_uninstall( allow_executables=_allow_executables, trust_bin=False, bin_skip_reason_override="not_retrusted_on_uninstall", + show_force_hint=False, ) deployed_files.extend(integration_result["deployed_files"]) except Exception as exc: @@ -1466,6 +1482,7 @@ def _sync_integrations_after_uninstall( diagnostics=reintegration_diagnostics, logger=logger, scope=_rebuild_scope, + show_force_hint=False, ) package_deployed_files["."] = root_result["deployed_files"] @@ -1478,15 +1495,13 @@ def _sync_integrations_after_uninstall( InstallResult(diagnostics=reintegration_diagnostics), force=False ) if reintegration_errors or rebuild_result.exit_code: - raise RuntimeError( - "Instruction aggregate rebuild failed. " - "Resolve the reported source errors, then run 'apm install'." - ) + raise AggregateIntegrationError("rebuild", aggregate_rebuilds) return IntegrationCleanupOutcome( counts=counts, deployed_files=package_deployed_files, failed_paths=failed_hook_paths, error_count=result.get("errors", 0), + removed_paths=frozenset(removed_aggregate_paths), ) diff --git a/src/apm_cli/commands/uninstall/lockfile_state.py b/src/apm_cli/commands/uninstall/lockfile_state.py index fd79fe5c0e..b7e365b68c 100644 --- a/src/apm_cli/commands/uninstall/lockfile_state.py +++ b/src/apm_cli/commands/uninstall/lockfile_state.py @@ -11,6 +11,7 @@ def reconcile_uninstall_deployment_state( deploy_root: Path, all_deployed_files: set[str], surviving_deployed_files: dict[str, set[str]], + removed_paths: frozenset[str] = frozenset(), fully_refreshed_dependency_keys: set[str] | None = None, ) -> bool: """Reconcile survivor ownership and hashes without persisting.""" @@ -31,6 +32,20 @@ def reconcile_uninstall_deployment_state( for record in previous_ledger.records.values(): records_by_value.setdefault(record.locator.value, []).append(record) materializations: list[MaterializationResult] = [] + # Only the cleanup owner can attest removal. Empty/failed integration or + # filesystem absence alone is not proof. Current successful replacements + # supersede these removals in DeploymentReconciler. + for path in sorted(removed_paths): + for prior in records_by_value.get(path, ()): + materializations.append( + MaterializationResult( + locator=prior.locator, + owners=frozenset(prior.owners), + status=MaterializationStatus.REMOVED, + content_hash=None, + validation=NativePayloadValidation(valid=True, contract="uninstall-cleanup"), + ) + ) for dep_key, deployed_files in surviving_deployed_files.items(): transferred = sorted(all_deployed_files.intersection(deployed_files)) hashes = compute_deployed_hashes(transferred, deploy_root) diff --git a/src/apm_cli/config.py b/src/apm_cli/config.py index d8d73e8da0..b84c986d8a 100644 --- a/src/apm_cli/config.py +++ b/src/apm_cli/config.py @@ -148,13 +148,16 @@ def set_auto_integrate(enabled: bool) -> None: update_config({"auto_integrate": enabled}) -def get_temp_dir() -> str | None: +def get_temp_dir(*, create_config: bool = True) -> str | None: """Get the configured temporary directory. + Args: + create_config: When false, leave missing user configuration absent. + Returns: The stored temp_dir config value, or None if not set. """ - return get_config().get("temp_dir") + return get_config(create=create_config).get("temp_dir") def set_temp_dir(path: str) -> None: @@ -660,7 +663,7 @@ def is_registry_default(name: str) -> bool: return bool(cfg and cfg.get("default") is True) -def get_apm_temp_dir() -> str | None: +def get_apm_temp_dir(*, create_config: bool = True) -> str | None: """Return the effective temporary directory for APM operations. Resolution order: @@ -670,13 +673,16 @@ def get_apm_temp_dir() -> str | None: Empty or whitespace-only values are treated as unset and skipped. + Args: + create_config: When false, leave missing user configuration absent. + Returns: Directory path string, or None when the system default should be used. """ env_val = os.environ.get("APM_TEMP_DIR", "").strip() if env_val: return env_val - config_val = (get_temp_dir() or "").strip() + config_val = (get_temp_dir(create_config=create_config) or "").strip() if config_val: return config_val return None diff --git a/src/apm_cli/core/deployment_ledger.py b/src/apm_cli/core/deployment_ledger.py index eb6eb8ba41..b74292ebd1 100644 --- a/src/apm_cli/core/deployment_ledger.py +++ b/src/apm_cli/core/deployment_ledger.py @@ -303,6 +303,17 @@ def apply_to_lockfile( dependency.deployed_file_hashes = dict(sorted(dependency_hashes[owner].items())) lockfile.local_deployed_files = sorted(dict.fromkeys(local_files)) lockfile.local_deployed_file_hashes = dict(sorted(local_hashes.items())) + # LockFile.read synthesizes "." from the local compatibility view. + # Keep that existing virtual view in step with the authoritative + # projection; it must not keep an otherwise empty lockfile alive. + if "." in lockfile.dependencies: + if local_files: + lockfile.dependencies["."].deployed_files = list(lockfile.local_deployed_files) + lockfile.dependencies["."].deployed_file_hashes = dict( + lockfile.local_deployed_file_hashes + ) + else: + del lockfile.dependencies["."] lockfile.mcp_target_servers = { runtime: sorted(dict.fromkeys(servers)) for runtime, servers in sorted(service_targets["mcp"].items()) diff --git a/src/apm_cli/core/deployment_state.py b/src/apm_cli/core/deployment_state.py index 9fde7705df..862b5f3c0e 100644 --- a/src/apm_cli/core/deployment_state.py +++ b/src/apm_cli/core/deployment_state.py @@ -203,22 +203,26 @@ def reconcile_package_claims( @staticmethod def merge_aggregate_records( - previous: dict[str, DeploymentRecord], - current: Mapping[str, DeploymentRecord], + batches: Iterable[Mapping[str, DeploymentRecord]], shared_paths: frozenset[str], ) -> dict[str, DeploymentRecord]: - """Accumulate current-run aggregate proofs without changing ordinary handoffs.""" - records = previous - for key, record in current.items(): - prior = records.get(key) - if prior is not None and record.locator.value in shared_paths: - record = DeploymentRecord( - locator=record.locator, - owners=tuple(dict.fromkeys((*prior.owners, *record.owners))), - active_owner=record.active_owner, - content_hash=record.content_hash, - ) - records[key] = record + """Collect ordered aggregate owners, freezing each final record only once.""" + records: dict[str, DeploymentRecord] = {} + aggregate_owners: dict[str, dict[str, None]] = {} + for batch in batches: + for key, record in batch.items(): + if record.locator.value in shared_paths: + owners = aggregate_owners.setdefault(key, {}) + owners.update(dict.fromkeys(record.owners)) + records[key] = record + for key, owners in aggregate_owners.items(): + record = records[key] + records[key] = DeploymentRecord( + locator=record.locator, + owners=tuple(owners), + active_owner=record.active_owner, + content_hash=record.content_hash, + ) return records def reconcile( diff --git a/src/apm_cli/deps/git_auth_env.py b/src/apm_cli/deps/git_auth_env.py index 5021fc920e..0365f407eb 100644 --- a/src/apm_cli/deps/git_auth_env.py +++ b/src/apm_cli/deps/git_auth_env.py @@ -83,13 +83,13 @@ def setup_environment(self) -> dict[str, Any]: @staticmethod def isolated_global_config_path() -> str: - """Return a cross-platform empty Git config path.""" + """Return an empty Git config path without bootstrapping user config.""" if sys.platform == "win32": import tempfile from ..config import get_apm_temp_dir - temp_base = get_apm_temp_dir() or tempfile.gettempdir() + temp_base = get_apm_temp_dir(create_config=False) or tempfile.gettempdir() empty_cfg = os.path.join(temp_base, ".apm_empty_gitconfig") with open(empty_cfg, "w", encoding="ascii"): pass diff --git a/src/apm_cli/install/drift.py b/src/apm_cli/install/drift.py index 4fde0c5033..95d9348c64 100644 --- a/src/apm_cli/install/drift.py +++ b/src/apm_cli/install/drift.py @@ -722,15 +722,29 @@ def _governed_root_dirs(targets: list[TargetProfile]) -> set[str]: return roots -def _walk_managed(root: Path, governed_roots: set[str]) -> dict[str, Path]: - """Return a mapping of project-relative posix paths to absolute paths.""" +def _walk_managed( + root: Path, + governed_roots: set[str], + *, + walked_dirs: set[Path] | None = None, +) -> dict[str, Path]: + """Collect files, visiting each safely covered directory once per comparison.""" + from apm_cli.utils.path_security import ensure_path_within, has_symlink_component + out: dict[str, Path] = {} if not root.exists(): return out - for top in governed_roots: + if walked_dirs is None: + walked_dirs = set() + for top in sorted(governed_roots, key=lambda path: path.rstrip("/").count("/")): base = root / top + if has_symlink_component(root, base): + continue + base = ensure_path_within(base, root) if not base.exists(): continue + if base in walked_dirs or any(parent in walked_dirs for parent in base.parents): + continue if base.is_file() and not base.is_symlink(): out[top] = base continue @@ -738,6 +752,9 @@ def _walk_managed(root: Path, governed_roots: set[str]) -> dict[str, Path]: if p.is_file() and not p.is_symlink(): rel = p.relative_to(root).as_posix() out[rel] = p + # Only validated directory walks can suppress descendant enumeration. + if base.is_dir(): + walked_dirs.add(base) # AGENTS.md is a flat top-level file in some target layouts. agents_md = root / "AGENTS.md" if agents_md.is_file() and not agents_md.is_symlink(): @@ -846,7 +863,8 @@ def diff_scratch_against_project( project_root = project_root.resolve() governed = governed_roots if governed_roots is not None else _governed_root_dirs(targets) scratch_files = _walk_managed(scratch_root, governed) - project_files = _walk_managed(project_root, governed) + walked_dirs: set[Path] = set() + project_files = _walk_managed(project_root, governed, walked_dirs=walked_dirs) from apm_cli.install.audit_target_roots import claims_for_root tracked = claims_for_root( @@ -867,7 +885,7 @@ def diff_scratch_against_project( # the old target. Claims widen comparison only, never source replay. from apm_cli.utils.path_security import ensure_path_within, has_symlink_component - for rel in tracked: + for rel in sorted(tracked, key=lambda path: path.rstrip("/").count("/")): if rel not in project_files: candidate = project_root / rel if has_symlink_component(project_root, candidate): @@ -876,7 +894,7 @@ def diff_scratch_against_project( if path.is_file(): project_files[rel] = path elif path.is_dir() and rel not in hashed_files: - project_files.update(_walk_managed(project_root, {rel})) + project_files.update(_walk_managed(project_root, {rel}, walked_dirs=walked_dirs)) claimed_prefixes = _claimed_prefixes(tracked, hashed_files, project_files) prefix_set = set(claimed_prefixes) prefix_owners = { @@ -884,7 +902,6 @@ def diff_scratch_against_project( for path, owner in tracked.items() if path.rstrip("/") + "/" in prefix_set } - owner_prefixes = sorted(prefix_owners, key=len, reverse=True) # Hook merge targets are shared with the user and never claimed in # deployed_files, so they can never be "unrecorded". Their APM-owned slice # is compared through hook_ownership; sidecars remain byte-for-byte owned. @@ -993,11 +1010,12 @@ def _is_canvas(rel: str) -> bool: if rel in scratch_files: continue owner = tracked.get(rel) - if owner is None: - owner = next( - (prefix_owners[prefix] for prefix in owner_prefixes if rel.startswith(prefix)), - None, - ) + # Exact claims win; otherwise probe only the deepest-to-shallowest + # parents, O(path depth) rather than scanning every directory claim. + parent = rel.rpartition("/")[0] + while owner is None and parent: + owner = prefix_owners.get(parent + "/") + parent = parent.rpartition("/")[0] if owner is not None: findings.append( DriftFinding( diff --git a/src/apm_cli/install/helpers/security_scan.py b/src/apm_cli/install/helpers/security_scan.py index d0623feb47..241fa33ee8 100644 --- a/src/apm_cli/install/helpers/security_scan.py +++ b/src/apm_cli/install/helpers/security_scan.py @@ -16,6 +16,7 @@ def _pre_deploy_security_scan( package_name: str = "", force: bool = False, logger=None, + show_force_hint: bool = True, ) -> bool: """Scan authorized deployable source files for hidden characters before deployment. @@ -50,10 +51,11 @@ def _pre_deploy_security_scan( logger.error_detail( " |-- Fix the reported file(s) in the package source, then reinstall" ) - logger.error_detail( - " |-- Use --force only after reviewing the reported findings " - f"from {printable_ascii_text(str(source_plan.source_root))}" - ) + if show_force_hint: + logger.error_detail( + " |-- Use --force only after reviewing the reported findings " + f"from {printable_ascii_text(str(source_plan.source_root))}" + ) return False return True diff --git a/src/apm_cli/install/phases/lockfile.py b/src/apm_cli/install/phases/lockfile.py index 8783941775..fb189f4da2 100644 --- a/src/apm_cli/install/phases/lockfile.py +++ b/src/apm_cli/install/phases/lockfile.py @@ -265,16 +265,30 @@ def _attach_deployed_files(self, lockfile: LockFile) -> None: # not removed, so a "Repaired inactive-target entries" report would be # misleading. lockfile_only = getattr(self.ctx, "lockfile_only", False) - canonical_records = {} + canonical_batches = [] + aggregate_hashes: dict[str, str | None] = {} + + def _invalidate_aggregate_hashes(cleanup) -> None: + for path in (*cleanup.deleted, *cleanup.retained): + aggregate_hashes.pop(path, None) + ghost_count = 0 for dep_key in package_keys: claim = package_claims[dep_key] current = list(claim.current_files) retained_hashes = cleanup_retained.get(dep_key, {}) + hashable = [path for path in current if path not in retained_hashes] current_hashes = compute_deployed_hashes( - (path for path in current if path not in retained_hashes), + (path for path in hashable if path not in shared_paths), self.ctx.project_root, ) + for path in shared_paths.intersection(hashable): + if path not in aggregate_hashes: + aggregate_hashes[path] = compute_deployed_hashes( + [path], self.ctx.project_root + ).get(path) + if aggregate_hashes[path] is not None: + current_hashes[path] = aggregate_hashes[path] prior_files = list(claim.prior_files) prior_hashes = claim.prior_hashes @@ -299,6 +313,7 @@ def _log_ghost_drop(path: str, package_key: str = dep_key) -> None: declared_targets=declared, diagnostics=diagnostics, on_ghost_drop=None if lockfile_only else _log_ghost_drop, + on_cleanup=_invalidate_aggregate_hashes, prior_ledger=prior_ledger, cleanup_retained_hashes=retained_hashes, current_run_trusted=diagnostics.count_for_package(dep_key, "error") == 0, @@ -312,11 +327,13 @@ def _log_ghost_drop(path: str, package_key: str = dep_key) -> None: # leave deployed_files untouched so the whole-dep # _merge_existing path can preserve it intact. continue - canonical_records = DeploymentReconciler.merge_aggregate_records( - canonical_records, ledger.records, shared_paths - ) + canonical_batches.append(ledger.records) + if canonical_batches: from apm_cli.core.deployment_ledger import DeploymentLedgerCodec + canonical_records = DeploymentReconciler.merge_aggregate_records( + canonical_batches, shared_paths + ) DeploymentLedgerCodec.apply_to_lockfile( DeploymentLedger(records=canonical_records), lockfile, @@ -505,17 +522,32 @@ def _preserve_existing_local_state(self, lockfile: LockFile) -> None: """Keep local fields until post_deps_local reconciles content hashes.""" if self.ctx.existing_lockfile: from apm_cli.core.deployment_ledger import DeploymentLedgerCodec - - DeploymentLedgerCodec.replace_legacy_owner( - lockfile, - ".", - list(self.ctx.existing_lockfile.local_deployed_files), - copy.deepcopy(self.ctx.existing_lockfile.local_deployed_file_hashes), + from apm_cli.integration.instruction_integrator import InstructionIntegrator + + # Attachment already proved the completed aggregate. Unlike other + # local outputs, USER aggregate hashes have no later local persist + # pass; never replace their current proof with a stale root hash. + current_aggregates = InstructionIntegrator.aggregate_paths( + self.ctx.targets + ).intersection(lockfile.local_deployed_files) + local_files = sorted( + set(self.ctx.existing_lockfile.local_deployed_files) | current_aggregates ) + local_hashes = copy.deepcopy(self.ctx.existing_lockfile.local_deployed_file_hashes) + for path in current_aggregates: + local_hashes.pop(path, None) + if path in lockfile.local_deployed_file_hashes: + local_hashes[path] = lockfile.local_deployed_file_hashes[path] if "." in self.ctx.existing_lockfile.dependencies: lockfile.dependencies["."] = copy.deepcopy( self.ctx.existing_lockfile.dependencies["."] ) + DeploymentLedgerCodec.replace_legacy_owner( + lockfile, + ".", + local_files, + local_hashes, + ) if self.ctx.logger: self.ctx.logger.verbose_detail( "Carrying forward local .apm state pending hash reconciliation: " diff --git a/src/apm_cli/install/services.py b/src/apm_cli/install/services.py index 5cf7a726c1..d02de1c38b 100644 --- a/src/apm_cli/install/services.py +++ b/src/apm_cli/install/services.py @@ -225,6 +225,7 @@ def integrate_package_primitives( # noqa: PLR0913 dep_target_subset: list[str] | None = None, trust_bin: bool | None = None, bin_skip_reason_override: str | None = None, + show_force_hint: bool = True, ) -> dict: """Run the full integration pipeline for a single package. @@ -365,6 +366,7 @@ def _reconcile_excluded_targets() -> None: package_name=package_name, force=force, logger=logger, + show_force_hint=show_force_hint, ): return result @@ -689,7 +691,7 @@ def _log_integration(msg): return result -def integrate_local_content( +def integrate_local_content( # noqa: PLR0913 -- explicit integration collaborator facade project_root: Path, *, targets: Any, @@ -706,6 +708,7 @@ def integrate_local_content( scope: InstallScope | None = None, source_root: Path | None = None, ctx: InstallContext | None = None, + show_force_hint: bool = True, ) -> dict: """Integrate primitives from the project's own .apm/ directory. @@ -769,6 +772,7 @@ def integrate_local_content( scope=scope, ctx=ctx, is_first_party=True, + show_force_hint=show_force_hint, ) diff --git a/src/apm_cli/install/sources.py b/src/apm_cli/install/sources.py index a99a1a1c12..50d3777c5c 100644 --- a/src/apm_cli/install/sources.py +++ b/src/apm_cli/install/sources.py @@ -466,6 +466,9 @@ def acquire(self) -> Materialization | None: package_path=install_path, source=dep_ref.repo_url, ) + from apm_cli.models.apm_package import restore_installed_package_source + + restore_installed_package_source(cached_package, dep_ref) if not cached_package.source: cached_package.source = dep_ref.repo_url diff --git a/src/apm_cli/integration/instruction_integrator.py b/src/apm_cli/integration/instruction_integrator.py index 89ed63ad6e..adb5f24dbc 100644 --- a/src/apm_cli/integration/instruction_integrator.py +++ b/src/apm_cli/integration/instruction_integrator.py @@ -498,7 +498,7 @@ def sync_for_target( *, managed_file_hashes: dict[str, str] | None = None, diagnostics: DiagnosticCollector | None = None, - ) -> dict[str, int]: + ) -> dict[str, int | tuple[str, ...]]: """Remove APM-managed instruction files for a single *target*.""" mapping = target.primitives.get("instructions") if not mapping: @@ -522,6 +522,7 @@ def sync_for_target( return { "files_removed": len(cleanup.deleted), "errors": len(cleanup.retained), + "removed_paths": tuple(cleanup.deleted), } else: prefix = f"{effective_root}/{mapping.subdir}/" diff --git a/src/apm_cli/models/apm_package.py b/src/apm_cli/models/apm_package.py index a8a3d62171..d5316b673b 100644 --- a/src/apm_cli/models/apm_package.py +++ b/src/apm_cli/models/apm_package.py @@ -845,6 +845,12 @@ def has_primitives(self) -> bool: return False +def restore_installed_package_source(package: APMPackage, dep_ref: DependencyReference) -> None: + """Restore Git acquisition provenance after loading installed authored metadata.""" + if dep_ref.source in (None, "git"): + package.source = dep_ref.to_github_url() + + def build_installed_package_info( dep_ref: DependencyReference, apm_modules_dir: Path ) -> PackageInfo | None: @@ -867,10 +873,7 @@ def build_installed_package_info( if not package: return None - if dep_ref.source in (None, "git"): - # Validation reconstructs authored metadata, not acquisition provenance. - # Reuse the identified Git source when rebuilding deployed sections. - package.source = dep_ref.to_github_url() + restore_installed_package_source(package, dep_ref) return PackageInfo( package=package, diff --git a/tests/integration/test_architecture_copilot_aggregate.py b/tests/integration/test_architecture_copilot_aggregate.py index 7614fd7f9c..a860171e3c 100644 --- a/tests/integration/test_architecture_copilot_aggregate.py +++ b/tests/integration/test_architecture_copilot_aggregate.py @@ -47,6 +47,31 @@ def test_copilot_aggregate_routes_are_registered_and_satisfied() -> None: "rebuild_result = finalize_install_result(", "rebuild_result = parallel_outcome(", ), + ( + "src/apm_cli/commands/uninstall/cli.py", + "removed_paths=(", + "discarded_removed_paths=(", + ), + ( + "src/apm_cli/commands/uninstall/engine.py", + "removed_paths=frozenset(removed_aggregate_paths)", + "removed_paths=frozenset()", + ), + ( + "src/apm_cli/commands/uninstall/lockfile_state.py", + "status=MaterializationStatus.REMOVED", + "status=MaterializationStatus.SKIPPED", + ), + ( + "src/apm_cli/install/sources.py", + "restore_installed_package_source(cached_package, dep_ref)", + "parallel_source(cached_package, dep_ref)", + ), + ( + "src/apm_cli/install/phases/lockfile.py", + "current_aggregates = InstructionIntegrator.aggregate_paths(", + "current_aggregates = parallel_aggregate_paths(", + ), ], ) def test_copilot_aggregate_guard_rejects_bypassed_owner(path: str, old: str, new: str) -> None: diff --git a/tests/integration/test_architecture_owner_rule_mutations.py b/tests/integration/test_architecture_owner_rule_mutations.py index 419c9aedf5..1696e3ebec 100644 --- a/tests/integration/test_architecture_owner_rule_mutations.py +++ b/tests/integration/test_architecture_owner_rule_mutations.py @@ -1204,3 +1204,44 @@ def test_owner_rule_catches_its_guard_mutation( f"({case.intent}) -- the guard has no teeth for this owner. " f"failures={[(f.stage, f.message) for f in report.failures]}" ) + + +@pytest.mark.parametrize( + ("path", "old", "new"), + [ + ( + "src/apm_cli/deps/git_auth_env.py", + "get_apm_temp_dir(create_config=False)", + "get_apm_temp_dir()", + ), + ( + "src/apm_cli/config.py", + "get_temp_dir(create_config=create_config)", + "get_temp_dir()", + ), + ( + "src/apm_cli/config.py", + 'get_config(create=create_config).get("temp_dir")', + 'get_config().get("temp_dir")', + ), + ], + ids=["sentinel-owner-route", "temp-owner-forwarding", "config-owner-forwarding"], +) +def test_auth_guard_requires_noncreating_sentinel_config_reads( + path: str, old: str, new: str +) -> None: + """Reject a bootstrap bypass at each edge of the existing temp-config owner.""" + rule_id = "transport-platform-host-credential-resolution" + baseline = run_selected_rules(ROOT, (rule_id,)) + assert baseline.failures == () + assert baseline.violations == () + source = _source(path) + assert source.count(old) == 1 + mutated = source.replace(old, new, 1) + ast.parse(mutated, filename=path) + + report = run_selected_rules(ROOT, (rule_id,), source_overrides={path: mutated}) + + assert report.failures == () + assert {finding.rule_id for finding in report.violations} == {rule_id} + assert {finding.path for finding in report.violations} == {path} diff --git a/tests/integration/test_copilot_aggregate_uninstall.py b/tests/integration/test_copilot_aggregate_uninstall.py index 7a07304603..27f7f6b062 100644 --- a/tests/integration/test_copilot_aggregate_uninstall.py +++ b/tests/integration/test_copilot_aggregate_uninstall.py @@ -13,7 +13,7 @@ from apm_cli.deps.lockfile import LockFile from apm_cli.models.dependency.selection import parse_dependency_entry from apm_cli.utils.yaml_io import dump_yaml, load_yaml -from tests.utils.apm_lifecycle_runner import ApmLifecycleRunner +from tests.utils.apm_lifecycle_runner import ApmLifecycleRunner, CommandResult from tests.utils.isolated_apm_environment import IsolatedApmEnvironment from tests.utils.local_git_repository import LocalGitRepositoryFactory from tests.utils.local_package import LocalPackageFactory @@ -30,6 +30,28 @@ _ROOT_BODY = "# Root contribution\nKeep the actual root-local instruction.\n" +def _assert_recovered_sections( + aggregate: Path, survivor_source: str, survivor_body: str, removed_body: str +) -> None: + """Validate exact source sections without imposing root/package ordering.""" + recovered = aggregate.read_text() + assert removed_body.strip() not in recovered + identities = re.findall(r"", recovered) + assert sorted(urlparse(identity) for identity in identities) == sorted( + [urlparse(survivor_source), urlparse("local")] + ) + sections = re.findall( + r"\n(.*?)\n", recovered, re.S + ) + assert len(sections) == 2 + assert {urlparse(source): body for source, body in sections} == { + urlparse(survivor_source): survivor_body.strip(), + urlparse("local"): _ROOT_BODY.strip(), + } + assert recovered.count(survivor_body.strip()) == 1 + assert recovered.count(_ROOT_BODY.strip()) == 1 + + @pytest.mark.parametrize( "obligation", [ @@ -41,6 +63,10 @@ "root", "edited", "unsafe-survivor", + "survivor-empty", + "survivor-missing", + "survivor-ineligible", + "root-missing", ], ) def test_generated_copilot_aggregate_lifecycle( @@ -85,7 +111,8 @@ def test_generated_copilot_aggregate_lifecycle( "dependencies": {"apm": declarations}, } dump_yaml(manifest, isolated.config_root / "apm.yml") - if obligation == "root": + has_root = obligation in {"root", "root-missing", "edited", "unsafe-survivor"} + if has_root: root_instruction = isolated.home / ".apm/instructions/root.instructions.md" root_instruction.parent.mkdir(parents=True) root_instruction.write_text(_ROOT_BODY, encoding="utf-8") @@ -99,13 +126,20 @@ def test_generated_copilot_aggregate_lifecycle( if obligation == "collision": aggregate.write_bytes(collision) runner = ApmLifecycleRunner([str(apm_binary_path)]) + project_manifest = consumer.manifest_path.read_bytes() + project_notes = consumer.root / "unowned-notes.md" + project_notes.write_bytes(_NOTES) - def command(*args: str, expected_returncode: int = 0) -> None: + def command(*args: str, expected_returncode: int = 0) -> CommandResult: result = runner.run(args, scenario_id=obligation, cwd=consumer.root, env=environment) print("COMMAND", result.command, "EXIT", result.returncode) print(result.stdout, result.stderr) assert result.returncode == expected_returncode, (result.stdout, result.stderr) assert notes.read_bytes() == _NOTES, "Unowned neighboring notes changed" + assert consumer.manifest_path.read_bytes() == project_manifest + assert project_notes.read_bytes() == _NOTES + assert not (consumer.root / "apm_modules").exists() + return result command( "install", "--global", "--target", "copilot", "--no-policy", "--parallel-downloads", "0" @@ -131,6 +165,9 @@ def command(*args: str, expected_returncode: int = 0) -> None: assert not lock.deployment_ledger.records, "Unmanaged output acquired an ownership claim" return assert initial.startswith(_HEADER) + records = list(lock.deployment_ledger.records.values()) + assert len(records) == 1 + assert set(records[0].owners) == expected_keys | ({"."} if has_root else set()) for body in bodies.values(): assert initial.count(body.strip()) == 1 if obligation == "initial-owners": @@ -139,26 +176,138 @@ def command(*args: str, expected_returncode: int = 0) -> None: assert set(records[0].owners) == expected_keys, "Initial aggregate lost an authored owner" assert records[0].active_owner in expected_keys return - if obligation == "root": + if has_root: assert initial.count(_ROOT_BODY.strip()) == 1 + aggregate_rel = ".copilot/copilot-instructions.md" + survivor_path = refs["survivor"].get_install_path(isolated.config_root / "apm_modules") + installed_instruction = survivor_path / ".apm/instructions/survivor.instructions.md" + + def assert_survivor_state() -> LockFile: + current_lock = LockFile.read(isolated.config_root / "apm.lock.yaml") + assert current_lock is not None + survivor = refs["survivor"] + assert set(current_lock.dependencies) == {survivor.get_unique_key()} | ( + {"."} if has_root else set() + ) + locked = current_lock.dependencies[survivor.get_unique_key()] + assert locked.name == "survivor" + assert locked.resolved_commit == commits["survivor"] + assert locked.to_dependency_ref().get_unique_key() == survivor.get_unique_key() + assert urlparse(locked.to_dependency_ref().to_github_url()) == urlparse( + survivor.to_github_url() + ) + assert ( + load_yaml(isolated.config_root / "apm.yml")["dependencies"]["apm"] == declarations[1:] + ) + assert not refs["primary"].get_install_path(isolated.config_root / "apm_modules").exists() + for relative, content in sources["survivor"].items(): + assert (survivor_path / relative).read_bytes() == content + return current_lock + + if obligation in {"survivor-empty", "survivor-missing", "survivor-ineligible"}: + relative = ".apm/instructions/survivor.instructions.md" + if obligation == "survivor-empty": + installed_instruction.write_bytes(b"") + sources["survivor"][relative] = b"" + elif obligation == "survivor-missing": + installed_instruction.unlink() + del sources["survivor"][relative] + else: + survivor_manifest = load_yaml(survivor_path / "apm.yml") + survivor_manifest["targets"] = ["claude"] + dump_yaml(survivor_manifest, survivor_path / "apm.yml") + sources["survivor"]["apm.yml"] = (survivor_path / "apm.yml").read_bytes() + command("uninstall", declarations[0]["git"], "--global") + assert not aggregate.exists() + current_lock = assert_survivor_state() + assert not current_lock.deployment_ledger.records, ( + "Removed aggregate retains canonical ownership" + ) + assert not current_lock.local_deployed_files + assert not current_lock.local_deployed_file_hashes + for dep in current_lock.dependencies.values(): + assert aggregate_rel not in dep.deployed_files + assert aggregate_rel not in dep.deployed_file_hashes + if obligation == "survivor-missing": + assert not installed_instruction.exists() + return if obligation == "edited": # Negative cleanup control, not a promise of editable generated regions. edited = aggregate.read_bytes() + b"\nChanged after install; refuse hash mismatch.\n" aggregate.write_bytes(edited) - command("uninstall", declarations[0]["git"], "--global", expected_returncode=1) + result = command("uninstall", declarations[0]["git"], "--global", expected_returncode=1) assert aggregate.read_bytes() == edited, "Hash-refused aggregate was overwritten" - return - if obligation == "unsafe-survivor": - installed_instruction = ( - refs["survivor"].get_install_path(isolated.config_root / "apm_modules") - / ".apm/instructions/survivor.instructions.md" - ) + elif obligation == "unsafe-survivor": installed_instruction.write_text("# Unsafe\nHidden \u202e instruction.\n", encoding="utf-8") - command("uninstall", declarations[0]["git"], "--global", expected_returncode=1) - assert not aggregate.exists(), "Rejected source was deployed during aggregate rebuild" + result = command("uninstall", declarations[0]["git"], "--global", expected_returncode=1) + assert bodies["survivor"].strip() not in aggregate.read_text(), ( + "Rejected source was deployed" + ) + if obligation in {"edited", "unsafe-survivor"}: + failed_lock = LockFile.read(isolated.config_root / "apm.lock.yaml") + assert failed_lock is not None + assert {dep.get_unique_key() for dep in failed_lock.get_package_dependencies()} == { + refs["survivor"].get_unique_key() + } + assert ( + load_yaml(isolated.config_root / "apm.yml")["dependencies"]["apm"] == declarations[1:] + ) + assert not refs["primary"].get_install_path(isolated.config_root / "apm_modules").exists() + failed_records = list(failed_lock.deployment_ledger.records.values()) + assert len(failed_records) == 1, "Failure erased retained aggregate ownership" + assert failed_records[0].content_hash == records[0].content_hash + output = " ".join((result.stdout + result.stderr).split()) + assert ( + "aggregate cleanup" in output.lower() + if obligation == "edited" + else "aggregate rebuild" in output.lower() + ) + assert aggregate_rel in output + assert "package removal" in output.lower() and "completed" in output.lower() + assert "apm install --global" in output + assert "Uninstall complete:" not in output + assert "--force" not in output + if obligation == "edited": + assert "back up" in output.lower() and "move" in output.lower() + backup = aggregate.with_name("copilot-instructions.md.review-backup") + aggregate.rename(backup) + assert backup.read_bytes() == edited + else: + assert "source" in output.lower() + installed_instruction.write_bytes( + sources["survivor"][".apm/instructions/survivor.instructions.md"] + ) + # An authorized root may already have rebuilt while the survivor was blocked. + # Execute the displayed scope-correct action from an unrelated project. + command("install", "--global", "--no-policy", "--parallel-downloads", "0") + current_lock = assert_survivor_state() + _assert_recovered_sections( + aggregate, refs["survivor"].to_github_url(), bodies["survivor"], bodies["primary"] + ) + assert root_instruction.read_text() == _ROOT_BODY + records = list(current_lock.deployment_ledger.records.values()) + assert len(records) == 1 + assert set(records[0].owners) == {refs["survivor"].get_unique_key(), "."} + assert ( + records[0].content_hash + == "sha256:" + hashlib.sha256(aggregate.read_bytes()).hexdigest() + ) + if obligation == "edited": + assert backup.read_bytes() == edited return command("uninstall", declarations[0]["git"], "--global") + if obligation == "root-missing": + assert_survivor_state() + root_instruction.unlink() + command("uninstall", declarations[1]["git"], "--global") + assert not aggregate.exists() + assert load_yaml(isolated.config_root / "apm.yml")["dependencies"]["apm"] == [] + assert not survivor_path.exists() + assert not (isolated.config_root / "apm.lock.yaml").exists(), ( + "Empty lockfile retained stale root ownership" + ) + return remaining = aggregate.read_text(encoding="utf-8") print("POST-UNINSTALL AGGREGATE", remaining) if obligation == "removed-section": @@ -185,21 +334,7 @@ def command(*args: str, expected_returncode: int = 0) -> None: ] else: survivor = refs["survivor"] - lock = LockFile.read(isolated.config_root / "apm.lock.yaml") - assert lock is not None - assert set(lock.dependencies) == {survivor.get_unique_key()}, "Survivor graph/key changed" - locked = lock.dependencies[survivor.get_unique_key()] - assert locked.name == "survivor" - assert locked.resolved_commit == commits["survivor"], "Survivor commit changed" - assert locked.to_dependency_ref().get_unique_key() == survivor.get_unique_key() - assert ( - load_yaml(isolated.config_root / "apm.yml")["dependencies"]["apm"] == declarations[1:] - ) - assert not refs["primary"].get_install_path(isolated.config_root / "apm_modules").exists() - for relative, content in sources["survivor"].items(): - assert ( - survivor.get_install_path(isolated.config_root / "apm_modules") / relative - ).read_bytes() == content + lock = assert_survivor_state() assert remaining.count(bodies["survivor"].strip()) == 1, "Survivor native content changed" assert remaining == ( f"{_HEADER}\n\n" diff --git a/tests/unit/install/phases/test_lockfile_cross_package_reconcile.py b/tests/unit/install/phases/test_lockfile_cross_package_reconcile.py index 559b9d3731..0207966466 100644 --- a/tests/unit/install/phases/test_lockfile_cross_package_reconcile.py +++ b/tests/unit/install/phases/test_lockfile_cross_package_reconcile.py @@ -16,11 +16,127 @@ from types import SimpleNamespace +import pytest + from apm_cli.core.deployment_state import DeploymentReconciler from apm_cli.deps.lockfile import LockedDependency, LockFile from apm_cli.install.phases.lockfile import LockfileBuilder +@pytest.mark.parametrize("obligation", ["hash", "projection", "owner-work"]) +def test_aggregate_attachment_work_is_linear(tmp_path, monkeypatch, obligation) -> None: + """Count attachment work, not latency or the separate concatenation algorithm.""" + from apm_cli.core import deployment_state + from apm_cli.core.deployment_ledger import DeploymentLedgerCodec + from apm_cli.core.scope import InstallScope + from apm_cli.install.phases import lockfile as attachment + from apm_cli.integration.targets import resolve_targets + + original_hash = attachment.compute_file_hash + original_record = deployment_state.DeploymentRecord + original_apply = DeploymentLedgerCodec.apply_to_lockfile + counts = {} + + def hash_file(path): + counts["hash"] += 1 + counts["bytes"] += path.stat().st_size + return original_hash(path) + + def record(**kwargs): + counts["owners"] += len(kwargs["owners"]) + return original_record(**kwargs) + + def project(ledger, lockfile): + counts["projection"] += 1 + return original_apply(ledger, lockfile) + + monkeypatch.setattr(attachment, "compute_file_hash", hash_file) + monkeypatch.setattr(deployment_state, "DeploymentRecord", record) + monkeypatch.setattr(DeploymentLedgerCodec, "apply_to_lockfile", project) + observations = [] + for size in (50, 500): + counts.update(hash=0, bytes=0, owners=0, projection=0) + root = tmp_path / str(size) + path = root / ".copilot/copilot-instructions.md" + path.parent.mkdir(parents=True) + path.write_bytes(b"fixed-width-contribution\n" * size) + lock = LockFile() + claims = {} + for i in range(size): + key = f"fixture/package-{i}" + lock.add_dependency(LockedDependency(repo_url=key)) + claims[key] = [".copilot/copilot-instructions.md"] + ctx = _ctx( + package_deployed_files=claims, + targets=resolve_targets(root, user_scope=True, explicit_target=["copilot"]), + project_root=root, + ) + ctx.scope = InstallScope.USER + LockfileBuilder(ctx)._attach_deployed_files(lock) + observations.append(dict(counts)) + records = list(lock.deployment_ledger.records.values()) + assert len(records) == 1 + assert records[0].owners == tuple(claims) + assert records[0].active_owner == list(claims)[-1] + assert records[0].content_hash == original_hash(path) + assert all( + dep.deployed_files == [".copilot/copilot-instructions.md"] + for dep in lock.dependencies.values() + ) + small, large = observations + print("ATTACHMENT WORK", observations) + if obligation == "hash": + assert small["hash"] == large["hash"] == 1 + assert large["bytes"] == 10 * small["bytes"] + elif obligation == "projection": + assert small["projection"] == large["projection"] == 1 + else: + assert large["owners"] < 15 * small["owners"] + + +def test_aggregate_attachment_invalidates_digest_after_cleanup(tmp_path, monkeypatch): + """A reconciliation cleanup handoff invalidates this phase's digest, not future commands.""" + from apm_cli.core.scope import InstallScope + from apm_cli.install import manifest_reconcile + from apm_cli.install.phases.lockfile import compute_deployed_hashes + from apm_cli.integration.cleanup import CleanupResult + from apm_cli.integration.targets import resolve_targets + + relative = ".copilot/copilot-instructions.md" + target = tmp_path / relative + target.parent.mkdir() + target.write_text("before cleanup\n") + original = manifest_reconcile.reconcile_deployed_block + calls = [] + + def reconcile(**kwargs): + result = original(**kwargs) + calls.append(kwargs) + if len(calls) == 1: + # Simulate the owner's cleanup callback, then a replacement. + # This fixture does not claim a native cleanup execution. + target.write_text("replacement following cleanup\n") + if kwargs.get("on_cleanup") is not None: + kwargs["on_cleanup"](CleanupResult(deleted=[relative])) + return result + + monkeypatch.setattr(manifest_reconcile, "reconcile_deployed_block", reconcile) + lock = LockFile() + for name in ("first", "second"): + lock.add_dependency(LockedDependency(repo_url=f"fixture/{name}")) + ctx = _ctx( + package_deployed_files={key: [relative] for key in lock.dependencies}, + targets=resolve_targets(tmp_path, user_scope=True, explicit_target=["copilot"]), + project_root=tmp_path, + ) + ctx.scope = InstallScope.USER + LockfileBuilder(ctx)._attach_deployed_files(lock) + assert ( + next(iter(lock.deployment_ledger.records.values())).content_hash + == compute_deployed_hashes([relative], tmp_path)[relative] + ) + + def _target(name, root_dir=".claude"): return SimpleNamespace(name=name, root_dir=root_dir, primitives={}) diff --git a/tests/unit/install/test_audit_target_roots.py b/tests/unit/install/test_audit_target_roots.py index 4d55c58d11..afc5bebf0e 100644 --- a/tests/unit/install/test_audit_target_roots.py +++ b/tests/unit/install/test_audit_target_roots.py @@ -1,6 +1,11 @@ """Current-intent audit resolution is read-only and scope-aware.""" +import json +import sys +from collections import Counter +from collections.abc import Iterator from pathlib import Path +from types import FrameType from unittest.mock import patch import pytest @@ -126,3 +131,139 @@ def test_removed_target_claims_remain_in_comparison(tmp_path: Path, claim_kind: if claim_kind == "replaced-file" else [DriftFinding(path=relative, kind="orphaned", package="owner/pkg")] ) + + +@pytest.mark.parametrize("work", ["owner-probes", "enumeration"]) +def test_directory_claim_work_is_bounded( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, work: str +) -> None: + """Fixed-depth 50/500 claims must not multiply probes or repeat safe walks.""" + from apm_cli.deps.lockfile import LockedDependency, LockFile + from apm_cli.install import drift + + measurements = [] + original_rglob = Path.rglob + for size in (50, 500): + project = tmp_path / str(size) / "project" + scratch = tmp_path / str(size) / "scratch" + scratch.mkdir(parents=True) + dependencies = {} + expected = [] + for index in range(size): + # Half already lie under a governed root; the rest need additional + # comparison roots. Deep claims deliberately precede their parents. + top = ".apm" if index % 2 == 0 else "retired" + parent = f"{top}/bundle-{index:04d}" + deep = f"{parent}/deep" + exact = f"{deep}/exact.md" + for claim, owner in ( + (deep + "/", f"deep/pkg-{index}"), + (parent, f"parent/pkg-{index}"), + (exact, f"exact/pkg-{index}"), + ): + dependencies[owner] = LockedDependency(repo_url=owner, deployed_files=[claim]) + for relative, owner in ( + (f"{parent}/outer.md", f"parent/pkg-{index}"), + (f"{deep}/orphan.md", f"deep/pkg-{index}"), + (exact, f"exact/pkg-{index}"), + # Similar spelling must not count as ancestor ownership. + (f".apm/bundle-{index:04d}-neighbor/note.md", None), + (f".apm/file-{index:04d}.md/neighbor.md", None), + ): + path = project / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("unchanged comparison-only bytes\n", encoding="utf-8") + if owner is not None: + expected.append(drift.DriftFinding(relative, "orphaned", owner)) + file_claim = f".apm/file-{index:04d}.md" + owner = f"hashed/pkg-{index}" + dependencies[owner] = LockedDependency( + repo_url=owner, + deployed_files=[file_claim], + deployed_file_hashes={file_claim: "sha256:" + "a" * 64}, + ) + + enumerated: Counter[Path] = Counter() + probes = 0 + + def counted_rglob( + path: Path, pattern: str, counts: Counter[Path] = enumerated + ) -> Iterator[Path]: + """Count real yielded filesystem entries, not elapsed time.""" + for entry in original_rglob(path, pattern): + counts[entry] += 1 + yield entry + + def count_owner_probes(frame: FrameType, event: str, arg: object) -> None: + """Count old prefix comparisons and replacement dictionary probes.""" + nonlocal probes + if event != "c_call" or frame.f_code.co_filename != drift.__file__: + return + name = getattr(arg, "__name__", "") + if name == "startswith" or ( + name == "get" + and getattr(arg, "__self__", None) is frame.f_locals.get("prefix_owners") + ): + probes += 1 + + before = ArtifactSnapshot.capture(project) + previous_profile = sys.getprofile() + with monkeypatch.context() as measured: + measured.setattr(Path, "rglob", counted_rglob) + try: + sys.setprofile(count_owner_probes) + findings = drift.diff_scratch_against_project( + scratch, project, LockFile(dependencies=dependencies), targets=[] + ) + finally: + sys.setprofile(previous_profile) + assert findings == sorted(expected, key=lambda finding: finding.path) + assert_unchanged(before, ArtifactSnapshot.capture(project)) + assert list(scratch.iterdir()) == [], "Comparison claims must not authorize replay" + measurements.append( + { + "size": size, + "owner-probes": probes, + "enumeration": enumerated.total(), + "max-visits": max(enumerated.values()), + } + ) + + print(json.dumps({"work": work, "measurements": measurements}, sort_keys=True)) + small, large = measurements + assert small[work] > 0, "The operation counter must observe the real comparison path" + assert large[work] < 15 * small[work], f"{work} grew at least 15x: {measurements}" + if work == "enumeration": + assert [sample["max-visits"] for sample in measurements] == [1, 1], ( + f"Validated covered roots were walked again: {measurements}" + ) + + +@pytest.mark.parametrize("unsafe", ["symlink", "escape"]) +def test_additional_directory_claims_keep_path_refusals(tmp_path: Path, unsafe: str) -> None: + """An unsafe old-target root is never eligible for a covered-root shortcut.""" + from apm_cli.deps.lockfile import LockedDependency, LockFile + from apm_cli.install.drift import diff_scratch_against_project + from apm_cli.utils.path_security import PathTraversalError + + project = tmp_path / "project" + scratch = tmp_path / "scratch" + outside = tmp_path / "outside" + for root in (project, scratch, outside): + root.mkdir() + (outside / "note.md").write_text("not managed\n", encoding="utf-8") + if unsafe == "symlink": + (project / "retired").symlink_to(outside, target_is_directory=True) + claim = "retired" + else: + claim = "../outside" + lock = LockFile( + dependencies={"owner/pkg": LockedDependency(repo_url="owner/pkg", deployed_files=[claim])} + ) + before = ArtifactSnapshot.capture(tmp_path) + if unsafe == "escape": + with pytest.raises(PathTraversalError): + diff_scratch_against_project(scratch, project, lock, targets=[]) + else: + assert diff_scratch_against_project(scratch, project, lock, targets=[]) == [] + assert_unchanged(before, ArtifactSnapshot.capture(tmp_path)) diff --git a/tests/unit/test_protocol_config_precedence.py b/tests/unit/test_protocol_config_precedence.py index 95ae0dc476..605f00e3ed 100644 --- a/tests/unit/test_protocol_config_precedence.py +++ b/tests/unit/test_protocol_config_precedence.py @@ -8,6 +8,8 @@ import json import os +import sys +import tempfile from pathlib import Path from unittest.mock import MagicMock, patch @@ -16,6 +18,8 @@ from tests.utils.artifact_snapshot import ArtifactSnapshot, assert_unchanged +@pytest.mark.windows_compat +@pytest.mark.parametrize("platform", ["linux", "win32"]) @pytest.mark.parametrize( ("create_config", "stored", "use_env", "expected_pref", "expected_fallback"), [ @@ -28,12 +32,14 @@ ) def test_downloader_config_bootstrap_preserves_transport_precedence( tmp_path: Path, + tmp_path_factory: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch, create_config: bool, stored: bool, use_env: bool, expected_pref: str | None, expected_fallback: bool, + platform: str, ) -> None: """Skipping initialization changes no configured or environment transport choice.""" from apm_cli import config @@ -44,8 +50,12 @@ def test_downloader_config_bootstrap_preserves_transport_precedence( monkeypatch.setattr(config, "CONFIG_DIR", str(config_path.parent)) monkeypatch.setattr(config, "CONFIG_FILE", str(config_path)) monkeypatch.setattr(config, "_config_cache", None) - for key in ("APM_GIT_PROTOCOL", "APM_ALLOW_PROTOCOL_FALLBACK"): + for key in ("APM_GIT_PROTOCOL", "APM_ALLOW_PROTOCOL_FALLBACK", "APM_TEMP_DIR"): monkeypatch.delenv(key, raising=False) + # Exercise the real Windows sentinel branch, not a stubbed config reader. + # Scratch Git config is allowed; missing user configuration is not. + scratch = tmp_path_factory.mktemp("git-sentinel") + monkeypatch.setattr(tempfile, "tempdir", str(scratch)) if stored: config_path.parent.mkdir() config_path.write_text( @@ -56,19 +66,66 @@ def test_downloader_config_bootstrap_preserves_transport_precedence( monkeypatch.setenv("APM_ALLOW_PROTOCOL_FALLBACK", "0") before = ArtifactSnapshot.capture(tmp_path) - if create_config: - downloader = GitHubPackageDownloader(auth_resolver=MagicMock()) - else: - downloader = GitHubPackageDownloader(auth_resolver=MagicMock(), create_config=False) + with monkeypatch.context() as platform_patch: + platform_patch.setattr(sys, "platform", platform) + if create_config: + downloader = GitHubPackageDownloader(auth_resolver=MagicMock()) + else: + downloader = GitHubPackageDownloader(auth_resolver=MagicMock(), create_config=False) assert downloader._protocol_pref is ProtocolPreference.from_str(expected_pref) assert downloader._allow_fallback is expected_fallback + assert downloader.git_env["GIT_ASKPASS"] == "echo" + assert downloader.git_env["GIT_CONFIG_NOSYSTEM"] == "1" + if platform == "win32": + sentinel = scratch / ".apm_empty_gitconfig" + assert downloader.git_env["GIT_CONFIG_GLOBAL"] == str(sentinel) + assert sentinel.read_bytes() == b"" + else: + assert downloader.git_env["GIT_CONFIG_GLOBAL"] == os.devnull if create_config: assert json.loads(config_path.read_text(encoding="utf-8")) == {"default_client": "vscode"} else: assert_unchanged(before, ArtifactSnapshot.capture(tmp_path)) +@pytest.mark.windows_compat +@pytest.mark.parametrize("use_env", [False, True], ids=["stored-temp", "env-temp"]) +def test_read_only_windows_sentinel_preserves_temp_precedence( + tmp_path: Path, + tmp_path_factory: pytest.TempPathFactory, + monkeypatch: pytest.MonkeyPatch, + use_env: bool, +) -> None: + """The sentinel uses env > saved temp without modifying saved configuration.""" + from apm_cli import config + from apm_cli.deps.github_downloader import GitHubPackageDownloader + + saved_temp = tmp_path_factory.mktemp("saved-temp") + env_temp = tmp_path_factory.mktemp("env-temp") + config_path = tmp_path / ".apm/config.json" + config_path.parent.mkdir() + config_path.write_text(json.dumps({"temp_dir": str(saved_temp)}), encoding="utf-8") + monkeypatch.setattr(config, "CONFIG_DIR", str(config_path.parent)) + monkeypatch.setattr(config, "CONFIG_FILE", str(config_path)) + monkeypatch.setattr(config, "_config_cache", None) + monkeypatch.delenv("APM_TEMP_DIR", raising=False) + if use_env: + monkeypatch.setenv("APM_TEMP_DIR", str(env_temp)) + before = ArtifactSnapshot.capture(tmp_path) + + with monkeypatch.context() as platform_patch: + platform_patch.setattr(sys, "platform", "win32") + downloader = GitHubPackageDownloader(auth_resolver=MagicMock(), create_config=False) + + expected_temp, unused_temp = (env_temp, saved_temp) if use_env else (saved_temp, env_temp) + sentinel = expected_temp / ".apm_empty_gitconfig" + assert downloader.git_env["GIT_CONFIG_GLOBAL"] == str(sentinel) + assert sentinel.read_bytes() == b"" + assert not (unused_temp / ".apm_empty_gitconfig").exists() + assert_unchanged(before, ArtifactSnapshot.capture(tmp_path)) + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- diff --git a/tests/unit/test_uninstall_engine_helpers.py b/tests/unit/test_uninstall_engine_helpers.py index e1435b7d77..98c950bc94 100644 --- a/tests/unit/test_uninstall_engine_helpers.py +++ b/tests/unit/test_uninstall_engine_helpers.py @@ -46,6 +46,54 @@ def _make_logger(): return logger +@pytest.mark.parametrize("user_scope", [False, True], ids=["project", "user"]) +@pytest.mark.parametrize("phase", ["cleanup", "rebuild"]) +def test_aggregate_recovery_is_visible_sanitized_and_scope_correct(tmp_path, user_scope, phase): + """Default semantic output preserves safe failure facts and the selected scope.""" + from apm_cli.commands.uninstall.cli import _sync_integrations_for_manifest + from apm_cli.commands.uninstall.engine import AggregateIntegrationError + + logger = _make_logger() + error = AggregateIntegrationError( + phase, frozenset({".copilot/copilot-instructions.md\x1b[31m"}) + ) + error.__cause__ = ValueError("private-source-token") + with ( + patch("apm_cli.commands.uninstall.cli.APMPackage.from_apm_yml"), + patch( + "apm_cli.commands.uninstall.cli._sync_integrations_after_uninstall", side_effect=error + ), + ): + outcome, reported_error = _sync_integrations_for_manifest( + manifest_path=tmp_path / "apm.yml", + deploy_root=tmp_path, + all_deployed_files=set(), + logger=logger, + user_scope=user_scope, + lockfile=None, + modules_dir=tmp_path / "apm_modules", + deployed_file_hashes={}, + default_counts={}, + ) + messages = "\n".join(call.args[0] for call in logger.warning.call_args_list) + assert f"aggregate {phase}" in messages + assert ".copilot/copilot-instructions.md" in messages + assert "\x1b" not in messages and "private-source-token" not in messages + assert "removal completed" in messages + expected = "apm install --global" if user_scope else "apm install" + assert f"Run '{expected}'" in messages + if not user_scope: + assert "--global" not in messages + assert "--force" not in messages + if phase == "cleanup": + assert "Back up and move" in messages + else: + assert "source errors" in messages + assert reported_error is error + assert outcome.removed_paths == frozenset() + logger.success.assert_not_called() + + # =========================================================================== # _parse_dependency_entry # =========================================================================== From 824b17058826aef375c0446085197a81e1418dd8 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Mon, 7 Sep 2026 08:06:25 +0200 Subject: [PATCH 18/34] test(architecture): keep aggregate mutation in deterministic inventory order Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../test_architecture_owner_rule_mutations.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/integration/test_architecture_owner_rule_mutations.py b/tests/integration/test_architecture_owner_rule_mutations.py index 1696e3ebec..daee4fa6d9 100644 --- a/tests/integration/test_architecture_owner_rule_mutations.py +++ b/tests/integration/test_architecture_owner_rule_mutations.py @@ -75,14 +75,6 @@ class MutationCase: MUTATIONS: tuple[MutationCase, ...] = ( - MutationCase( - guard_id="install-deployment-copilot-aggregate", - rule_id="install-deployment-copilot-aggregate", - path="src/apm_cli/install/phases/lockfile.py", - old="DeploymentReconciler.merge_aggregate_records(", - new="parallel_aggregate_merge(", - intent="Aggregate ledger accumulation bypasses the canonical ownership reconciler.", - ), MutationCase( guard_id="contracts-tests-taxonomy-classification", rule_id="contracts-tests-taxonomy-classification", @@ -301,6 +293,14 @@ class MutationCase: new='if target.name == "copilot" and mapping is not None:', intent="Local bundle routing branches on target names instead of target primitives.", ), + MutationCase( + guard_id="install-deployment-copilot-aggregate", + rule_id="install-deployment-copilot-aggregate", + path="src/apm_cli/install/phases/lockfile.py", + old="DeploymentReconciler.merge_aggregate_records(", + new="parallel_aggregate_merge(", + intent="Aggregate ledger accumulation bypasses the canonical ownership reconciler.", + ), MutationCase( guard_id="install-deployment-executable-trust-context", rule_id="install-deployment-executable-trust-context", From 897e15a320553f1ae724185a92d39672ad3a7169 Mon Sep 17 00:00:00 2001 From: Danvs60 Date: Mon, 7 Sep 2026 17:35:12 +0100 Subject: [PATCH 19/34] fix(alias): reject path-traversing dependency aliases Aliases were only matched against a lax character regex, letting values like "..", "./x", or "foo/../bar" escape apm_modules at download and integrate time. Now validating every parsed alias through path_security's segment checks and guarding the resolved install path inside the download/integrate phases. Adds unit coverage for traversal and safe aliases. --- src/apm_cli/install/phases/download.py | 4 ++ src/apm_cli/install/phases/integrate.py | 3 + .../models/dependency/object_fields.py | 2 + .../models/dependency/registry_entry.py | 1 + tests/unit/test_alias_traversal.py | 69 +++++++++++++++++++ 5 files changed, 79 insertions(+) create mode 100644 tests/unit/test_alias_traversal.py diff --git a/src/apm_cli/install/phases/download.py b/src/apm_cli/install/phases/download.py index 5a171520fb..50878fb83c 100644 --- a/src/apm_cli/install/phases/download.py +++ b/src/apm_cli/install/phases/download.py @@ -59,6 +59,10 @@ def run(ctx: InstallContext) -> None: if _pd_ref.alias else _pd_ref.get_install_path(apm_modules_dir) ) + if _pd_ref.alias: + from apm_cli.utils.path_security import ensure_path_within + + ensure_path_within(_pd_path, apm_modules_dir) # type: ignore[arg-type] # 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..af8e8293e7 100644 --- a/src/apm_cli/install/phases/integrate.py +++ b/src/apm_cli/install/phases/integrate.py @@ -617,6 +617,9 @@ def run(ctx: InstallContext) -> None: if dep_ref.alias: # If alias is provided, use it directly (assume user handles namespacing) install_path = apm_modules_dir / dep_ref.alias + from apm_cli.utils.path_security import ensure_path_within + + ensure_path_within(install_path, apm_modules_dir) # type: ignore[arg-type] else: # Use the canonical install path from DependencyReference install_path = dep_ref.get_install_path(apm_modules_dir) diff --git a/src/apm_cli/models/dependency/object_fields.py b/src/apm_cli/models/dependency/object_fields.py index 0efa662058..2022dfe42a 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 validate_path_segments from .subsets import parse_skill_subset, parse_target_subset _ALIAS_PATTERN = re.compile(r"^[a-zA-Z0-9._-]+$") @@ -36,6 +37,7 @@ def parse_alias_override(raw: object) -> str | None: f"Invalid alias: {alias}. Aliases can only contain " "letters, numbers, dots, underscores, and hyphens" ) + validate_path_segments(alias, context="dependency alias") return alias diff --git a/src/apm_cli/models/dependency/registry_entry.py b/src/apm_cli/models/dependency/registry_entry.py index bc411aeb26..accc490327 100644 --- a/src/apm_cli/models/dependency/registry_entry.py +++ b/src/apm_cli/models/dependency/registry_entry.py @@ -83,6 +83,7 @@ def parse_registry_object_entry(dependency_reference_cls: Any, entry: dict) -> A f"Invalid alias: {alias}. Aliases can only contain " f"letters, numbers, dots, underscores, and hyphens" ) + validate_path_segments(alias, context="dependency alias") skills_raw = entry.get("skills") skill_subset = parse_skill_subset(skills_raw) if skills_raw is not None else None diff --git a/tests/unit/test_alias_traversal.py b/tests/unit/test_alias_traversal.py new file mode 100644 index 0000000000..1fb62d11e0 --- /dev/null +++ b/tests/unit/test_alias_traversal.py @@ -0,0 +1,69 @@ +"""Unit tests for traversal alias rejection (alias-poisoning fix).""" + +import pytest + +from apm_cli.models.dependency.object_fields import parse_alias_override +from apm_cli.utils.path_security import PathTraversalError + + +class TestParseAliasOverrideTraversal: + """Reject aliases that navigate outside apm_modules.""" + + def test_single_dot_rejected(self): + with pytest.raises((ValueError, PathTraversalError)): + parse_alias_override(".") + + def test_double_dot_rejected(self): + with pytest.raises((ValueError, PathTraversalError)): + parse_alias_override("..") + + def test_traversal_segment_rejected(self): + with pytest.raises((ValueError, PathTraversalError)): + parse_alias_override("foo/../bar") + + def test_leading_dot_slash_rejected(self): + with pytest.raises((ValueError, PathTraversalError)): + parse_alias_override("./safe-name") + + def test_trailing_dot_dot_rejected(self): + with pytest.raises((ValueError, PathTraversalError)): + parse_alias_override("pkg/..") + + def test_encoded_dot_dot_rejected(self): + with pytest.raises((ValueError, PathTraversalError)): + parse_alias_override("%2e%2e") + + def test_double_encoded_dot_dot_rejected(self): + with pytest.raises((ValueError, PathTraversalError)): + 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" + + 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!") From 38608f47f8e6c8c7a8ba713c69c24557a562be82 Mon Sep 17 00:00:00 2001 From: Danvs60 Date: Tue, 8 Sep 2026 11:21:47 +0100 Subject: [PATCH 20/34] fix(alias): surface friendly error for traversal aliases Wrap validate_path_segments in parse_alias_override so '.'/'..' aliases translate PathTraversalError into the existing allowed-character message instead of leaking a low-level technical error. Strengthen traversal tests to assert the friendly message. --- .../models/dependency/object_fields.py | 10 +++++-- tests/unit/test_alias_traversal.py | 29 ++++++++++++++----- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/src/apm_cli/models/dependency/object_fields.py b/src/apm_cli/models/dependency/object_fields.py index 2022dfe42a..cd2329406d 100644 --- a/src/apm_cli/models/dependency/object_fields.py +++ b/src/apm_cli/models/dependency/object_fields.py @@ -6,7 +6,7 @@ from collections.abc import Collection from typing import Any -from ...utils.path_security import validate_path_segments +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._-]+$") @@ -37,7 +37,13 @@ def parse_alias_override(raw: object) -> str | None: f"Invalid alias: {alias}. Aliases can only contain " "letters, numbers, dots, underscores, and hyphens" ) - validate_path_segments(alias, context="dependency alias") + try: + validate_path_segments(alias, context="dependency alias") + except PathTraversalError: + raise ValueError( + f"Invalid alias: {alias}. Aliases can only contain " + "letters, numbers, dots, underscores, and hyphens" + ) from None return alias diff --git a/tests/unit/test_alias_traversal.py b/tests/unit/test_alias_traversal.py index 1fb62d11e0..e7981452bd 100644 --- a/tests/unit/test_alias_traversal.py +++ b/tests/unit/test_alias_traversal.py @@ -3,38 +3,51 @@ import pytest from apm_cli.models.dependency.object_fields import parse_alias_override -from apm_cli.utils.path_security import PathTraversalError class TestParseAliasOverrideTraversal: """Reject aliases that navigate outside apm_modules.""" def test_single_dot_rejected(self): - with pytest.raises((ValueError, PathTraversalError)): + with pytest.raises( + ValueError, match="Aliases can only contain letters, numbers, dots, underscores, and hyphens" + ): parse_alias_override(".") def test_double_dot_rejected(self): - with pytest.raises((ValueError, PathTraversalError)): + with pytest.raises( + ValueError, match="Aliases can only contain letters, numbers, dots, underscores, and hyphens" + ): parse_alias_override("..") def test_traversal_segment_rejected(self): - with pytest.raises((ValueError, PathTraversalError)): + 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, PathTraversalError)): + 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, PathTraversalError)): + 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, PathTraversalError)): + 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, PathTraversalError)): + with pytest.raises( + ValueError, match="Aliases can only contain letters, numbers, dots, underscores, and hyphens" + ): parse_alias_override("%252e%252e") From 206d489fe183af17a66e3dd6f6fba4bdc9f2360c Mon Sep 17 00:00:00 2001 From: Danvs60 Date: Tue, 8 Sep 2026 11:27:52 +0100 Subject: [PATCH 21/34] test(alias): prove install-phase symlink escape is blocked A valid alias like 'safe-name' passes parse-time validation, but apm_modules_dir/safe-name can itself be a symlink pointing outside apm_modules_dir. ensure_path_within is the only guard that resolves symlinks before containment (download.py:65, integrate.py:622); this test proves the escape raises PathTraversalError and never writes outside the managed tree. --- tests/unit/test_alias_traversal.py | 38 ++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/unit/test_alias_traversal.py b/tests/unit/test_alias_traversal.py index e7981452bd..912be9d534 100644 --- a/tests/unit/test_alias_traversal.py +++ b/tests/unit/test_alias_traversal.py @@ -3,6 +3,7 @@ import pytest from apm_cli.models.dependency.object_fields import parse_alias_override +from apm_cli.utils.path_security import PathTraversalError, ensure_path_within class TestParseAliasOverrideTraversal: @@ -80,3 +81,40 @@ def test_whitespace_only_rejected(self): 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; + the install-phase code invokes it at download.py:65 and integrate.py:622 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" From 9da1ecd675d07847bfe4cbd1c5af0b92fcd257c4 Mon Sep 17 00:00:00 2001 From: Danvs60 Date: Tue, 8 Sep 2026 11:39:07 +0100 Subject: [PATCH 22/34] test(alias): trap ensure_path_within guards end-to-end at install tier The parser rejects traversal aliases (parse_alias_override), and the ensure_path_within containment guards exist at download.py:65 and integrate.py:622 as the defense-in-depth last line. But no test drove those guards through the real phase entry points -- the PR "Scenario 4" claim (install path can never escape apm_modules even if the parser is bypassed) was proven only at unit tier. Add install-tier regression traps that route malicious aliases through the actual phase run() functions: - tests/red_team/install/test_alias_path_escape.py * download.run() rejects a '..' traversal alias and a symlink that resolves outside apm_modules_dir via PathTraversalError, asserting no download bytes land. * integrate.run() rejects the same two vectors before materialization. * Safe-alias controls confirm no false positive. - tests/unit/test_registry_entry_alias_traversal.py * Covers the secondary parse_registry_object_entry alias validation (registry_entry.py:86): regex layer (%2e%2e) and validate_path_segments layer ('..', 'pkg/..'), plus a safe-alias affirmative control. --- .../install/test_alias_path_escape.py | 220 ++++++++++++++++++ .../test_registry_entry_alias_traversal.py | 85 +++++++ 2 files changed, 305 insertions(+) create mode 100644 tests/red_team/install/test_alias_path_escape.py create mode 100644 tests/unit/test_registry_entry_alias_traversal.py 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..ffa1b8f1c7 --- /dev/null +++ b/tests/red_team/install/test_alias_path_escape.py @@ -0,0 +1,220 @@ +"""Regression traps for the ensure_path_within defense-in-depth guards. + +The install pipeline computes an ``install_path`` from a user-supplied alias +in two phases: + +- ``src/apm_cli/install/phases/download.py:65`` -- before any download bytes + land at an alias-derived destination. +- ``src/apm_cli/install/phases/integrate.py:622`` -- before the alias-derived + path is handed to materialization / integration. + +Even when the parser (``parse_alias_override``) is bypassed -- e.g. a crafted +DependencyReference carrying an alias that navigates with ``..`` or a symlink +that resolves outside ``apm_modules_dir`` -- these guards are the last line +that keeps ``install_path`` inside ``apm_modules_dir`` (the PR body "Scenario +4" claim: the install path can never escape apm_modules even if the parser is +bypassed). + +These tests drive both phase entry points directly with malicious aliases and +assert the guard raises before any bytes are written / materialized. +""" + +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.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) -> MagicMock: + """Build a MagicMock DependencyReference carrying a crafted *alias*. + + ``is_local`` defaults to True so the guard -- which fires BEFORE the + local-skip branch in both phases -- is exercised without needing to + mock the full git downloader. + """ + dep = MagicMock() + dep.get_unique_key.return_value = key + dep.get_identity.return_value = key + dep.get_display_name.return_value = key + dep.alias = alias + dep.is_local = is_local + dep.local_path = "./local" if is_local else None + return dep + + +# --------------------------------------------------------------------------- +# Download phase guard (download.py:65) +# --------------------------------------------------------------------------- + + +class TestDownloadRejectsEscapingAlias: + def test_dotdot_alias_rejected(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="../../etc")] + + with pytest.raises(PathTraversalError): + run(ctx) + + ctx.downloader.download_package.assert_not_called() + + def test_symlink_escape_alias_rejected(self, tmp_path: Path) -> 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) + 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: + def test_dotdot_alias_rejected(self, tmp_path: Path) -> 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="../../etc")] + + with pytest.raises(PathTraversalError): + run(ctx) + + def test_symlink_escape_alias_rejected(self, tmp_path: Path) -> 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) + 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/unit/test_registry_entry_alias_traversal.py b/tests/unit/test_registry_entry_alias_traversal.py new file mode 100644 index 0000000000..fd59f61f5a --- /dev/null +++ b/tests/unit/test_registry_entry_alias_traversal.py @@ -0,0 +1,85 @@ +"""Regression traps for the object-form registry alias validation. + +``parse_registry_object_entry`` (registry_entry.py) validates the ``alias`` +field in two layers: + +1. ``_ALIAS_PATTERN`` (registry_entry.py:81) -- rejects any character outside + letters / digits / dot / underscore / hyphen. This blocks ``/`` so most + traversal payloads never reach the next layer. +2. ``validate_path_segments`` (registry_entry.py:86) -- the defense-in-depth + containment/segment check that catches ``..`` payloads the regex permits + (dashes and dots are legal in an alias). + +These tests regression-trap both layers so the object-form registry parser +cannot silently install at an escaping alias path. +""" + +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_nested_dotdot_alias_rejected(self): + """A dotted lookup-style alias resolves to a traversal in + validate_path_segments when the regex allows '..' segments.""" + cls = _make_reference_cls() + with pytest.raises((ValueError, PathTraversalError)): + parse_registry_object_entry( + cls, + {"id": "org/pkg", "version": "v1", "alias": "pkg/.."}, + ) + + 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" From e845ebf33452e393d3c418c389ad592d31bc7477 Mon Sep 17 00:00:00 2001 From: Daniel <47431549+Danvs60@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:30:43 +0100 Subject: [PATCH 23/34] Refactor install path handling for dependencies Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/apm_cli/install/phases/integrate.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/apm_cli/install/phases/integrate.py b/src/apm_cli/install/phases/integrate.py index af8e8293e7..1499af5ae9 100644 --- a/src/apm_cli/install/phases/integrate.py +++ b/src/apm_cli/install/phases/integrate.py @@ -616,10 +616,11 @@ def run(ctx: InstallContext) -> None: # 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 from apm_cli.utils.path_security import ensure_path_within - ensure_path_within(install_path, apm_modules_dir) # type: ignore[arg-type] + assert apm_modules_dir is not None + install_path = apm_modules_dir / dep_ref.alias + ensure_path_within(install_path, apm_modules_dir) else: # Use the canonical install path from DependencyReference install_path = dep_ref.get_install_path(apm_modules_dir) From e2015dbba7aacd72693e4023e20f3030f1be8493 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Tue, 8 Sep 2026 13:59:00 +0200 Subject: [PATCH 24/34] fix(alias): centralize safe destinations without restricting sources Address the PR #2901 panel and Copilot follow-ups: route all alias ingress and materialization through existing owners, reject root-equal destinations, preserve local sibling sources, and defend real reinstall metadata and hashes with regression and architecture tests. Include actionable diagnostics and migration guidance. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../owners/contracts-tooling.json | 3 +- CHANGELOG.md | 1 + .../docs/consumer/manage-dependencies.md | 2 + .../content/docs/reference/manifest-schema.md | 2 +- .../content/docs/troubleshooting/migration.md | 17 +++++ .../.apm/skills/apm-usage/dependencies.md | 6 +- .../checks/contracts_test_taxonomy.py | 36 ++++++++++ src/apm_cli/install/phases/download.py | 12 +--- src/apm_cli/install/phases/integrate.py | 12 +--- .../models/dependency/materialization.py | 16 ++++- .../models/dependency/object_fields.py | 4 +- src/apm_cli/models/dependency/reference.py | 5 +- .../models/dependency/registry_entry.py | 14 +--- .../test_architecture_dependency_reference.py | 52 ++++++++++++++ .../test_required_lifecycle_state_machine.py | 30 +++++++- .../integration/test_transitive_chain_e2e.py | 29 ++++++-- .../install/test_alias_path_escape.py | 71 ++++++++----------- tests/unit/test_alias_traversal.py | 42 +++++++---- .../test_registry_entry_alias_traversal.py | 25 ++----- 19 files changed, 253 insertions(+), 126 deletions(-) diff --git a/.apm/architecture/owners/contracts-tooling.json b/.apm/architecture/owners/contracts-tooling.json index 31ef286093..a898d993bb 100644 --- a/.apm/architecture/owners/contracts-tooling.json +++ b/.apm/architecture/owners/contracts-tooling.json @@ -4,10 +4,11 @@ { "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) + 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/object_fields.py", "src/apm_cli/models/dependency/reference.py" ], "guards": ["contracts-tooling-dependency-identity", "contracts-tooling-policy-identity"] diff --git a/CHANGELOG.md b/CHANGELOG.md index fc6df1ff7f..75cd4f8291 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- Dependency aliases now reject `.` and `..` and require destinations strictly inside `apm_modules/` after symlink resolution; [rename rejected aliases and review 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/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/manifest-schema.md b/docs/src/content/docs/reference/manifest-schema.md index 856f5c5f95..877d110c38 100644 --- a/docs/src/content/docs/reference/manifest-schema.md +++ b/docs/src/content/docs/reference/manifest-schema.md @@ -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/troubleshooting/migration.md b/docs/src/content/docs/troubleshooting/migration.md index 3993dd6380..849547fd5a 100644 --- a/docs/src/content/docs/troubleshooting/migration.md +++ b/docs/src/content/docs/troubleshooting/migration.md @@ -105,6 +105,23 @@ 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. + ## 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 e4efb1d83c..45dbeec072 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/dependencies.md +++ b/packages/apm-guide/.apm/skills/apm-usage/dependencies.md @@ -173,7 +173,7 @@ 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 | Install only named skills from a skill bundle. | @@ -205,7 +205,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. | @@ -333,7 +333,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/scripts/architecture_linter/checks/contracts_test_taxonomy.py b/scripts/architecture_linter/checks/contracts_test_taxonomy.py index 785c8a4064..195982fe44 100644 --- a/scripts/architecture_linter/checks/contracts_test_taxonomy.py +++ b/scripts/architecture_linter/checks/contracts_test_taxonomy.py @@ -469,6 +469,42 @@ def check_dependency_identity(provider: FactsProvider) -> tuple[Violation, ...]: return tuple(failures) findings: list[Violation] = [] + alias_consumers = { + _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 == apm_modules_dir.resolve():", + "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)", + ), + } + for path, required in alias_consumers.items(): + facts, errors = _facts_for(provider, path, rule_id) + findings.extend(errors) + if not errors and ( + any(not _present(facts, needle) for needle in required) + or _present_re(facts, re.compile(r"/\s*\w+\.alias\b")) + ): + 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/src/apm_cli/install/phases/download.py b/src/apm_cli/install/phases/download.py index 50878fb83c..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,15 +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) - ) - if _pd_ref.alias: - from apm_cli.utils.path_security import ensure_path_within - - ensure_path_within(_pd_path, apm_modules_dir) # type: ignore[arg-type] + _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 af8e8293e7..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,15 +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 - from apm_cli.utils.path_security import ensure_path_within - - ensure_path_within(install_path, apm_modules_dir) # type: ignore[arg-type] - 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/models/dependency/materialization.py b/src/apm_cli/models/dependency/materialization.py index 4266defed7..5a7eaf47a0 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,17 @@ 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 + resolved = ensure_path_within(result, apm_modules_dir) + if resolved == apm_modules_dir.resolve(): + 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 +340,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 cd2329406d..fb1816341f 100644 --- a/src/apm_cli/models/dependency/object_fields.py +++ b/src/apm_cli/models/dependency/object_fields.py @@ -41,8 +41,8 @@ def parse_alias_override(raw: object) -> str | None: validate_path_segments(alias, context="dependency alias") except PathTraversalError: raise ValueError( - f"Invalid alias: {alias}. Aliases can only contain " - "letters, numbers, dots, underscores, and hyphens" + 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 accc490327..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,17 +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" - ) - validate_path_segments(alias, context="dependency alias") + 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/tests/integration/test_architecture_dependency_reference.py b/tests/integration/test_architecture_dependency_reference.py index e472905931..b7abb17ee7 100644 --- a/tests/integration/test_architecture_dependency_reference.py +++ b/tests/integration/test_architecture_dependency_reference.py @@ -44,3 +44,55 @@ 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"), + [ + (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 == apm_modules_dir.resolve():", + "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", + ), + ], +) +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_required_lifecycle_state_machine.py b/tests/integration/test_required_lifecycle_state_machine.py index d4c35f4e0e..3eaf11ea44 100644 --- a/tests/integration/test_required_lifecycle_state_machine.py +++ b/tests/integration/test_required_lifecycle_state_machine.py @@ -1230,9 +1230,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( @@ -1243,7 +1245,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",), ) @@ -1291,6 +1294,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( 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 index ffa1b8f1c7..5bb6200a05 100644 --- a/tests/red_team/install/test_alias_path_escape.py +++ b/tests/red_team/install/test_alias_path_escape.py @@ -1,22 +1,9 @@ -"""Regression traps for the ensure_path_within defense-in-depth guards. +"""Both install phases must consult the strict alias materialization owner. -The install pipeline computes an ``install_path`` from a user-supplied alias -in two phases: - -- ``src/apm_cli/install/phases/download.py:65`` -- before any download bytes - land at an alias-derived destination. -- ``src/apm_cli/install/phases/integrate.py:622`` -- before the alias-derived - path is handed to materialization / integration. - -Even when the parser (``parse_alias_override``) is bypassed -- e.g. a crafted -DependencyReference carrying an alias that navigates with ``..`` or a symlink -that resolves outside ``apm_modules_dir`` -- these guards are the last line -that keeps ``install_path`` inside ``apm_modules_dir`` (the PR body "Scenario -4" claim: the install path can never escape apm_modules even if the parser is -bypassed). - -These tests drive both phase entry points directly with malicious aliases and -assert the guard raises before any bytes are written / materialized. +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 @@ -27,6 +14,7 @@ import pytest from apm_cli.install.context import InstallContext +from apm_cli.models.dependency import DependencyReference from apm_cli.utils.path_security import PathTraversalError # --------------------------------------------------------------------------- @@ -94,21 +82,14 @@ def _make_integrate_ctx(tmp_path: Path) -> InstallContext: return ctx -def _make_dep_ref(key: str, *, alias: str, is_local: bool = True) -> MagicMock: - """Build a MagicMock DependencyReference carrying a crafted *alias*. - - ``is_local`` defaults to True so the guard -- which fires BEFORE the - local-skip branch in both phases -- is exercised without needing to - mock the full git downloader. - """ - dep = MagicMock() - dep.get_unique_key.return_value = key - dep.get_identity.return_value = key - dep.get_display_name.return_value = key - dep.alias = alias - dep.is_local = is_local - dep.local_path = "./local" if is_local else None - return dep +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, + ) # --------------------------------------------------------------------------- @@ -117,25 +98,27 @@ def _make_dep_ref(key: str, *, alias: str, is_local: bool = True) -> MagicMock: class TestDownloadRejectsEscapingAlias: - def test_dotdot_alias_rejected(self, tmp_path: Path) -> None: + @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="../../etc")] + ctx.deps_to_install = [_make_dep_ref("org/pkg", alias=alias)] - with pytest.raises(PathTraversalError): + with pytest.raises(ValueError): run(ctx) ctx.downloader.download_package.assert_not_called() - def test_symlink_escape_alias_rejected(self, tmp_path: Path) -> None: + @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) + 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): @@ -150,23 +133,25 @@ def test_symlink_escape_alias_rejected(self, tmp_path: Path) -> None: class TestIntegrateRejectsEscapingAlias: - def test_dotdot_alias_rejected(self, tmp_path: Path) -> None: + @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="../../etc")] + ctx.deps_to_install = [_make_dep_ref("org/pkg", alias=alias)] - with pytest.raises(PathTraversalError): + with pytest.raises(ValueError): run(ctx) - def test_symlink_escape_alias_rejected(self, tmp_path: Path) -> None: + @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) + 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): diff --git a/tests/unit/test_alias_traversal.py b/tests/unit/test_alias_traversal.py index 912be9d534..1bf123bcca 100644 --- a/tests/unit/test_alias_traversal.py +++ b/tests/unit/test_alias_traversal.py @@ -2,6 +2,7 @@ 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 @@ -10,44 +11,53 @@ class TestParseAliasOverrideTraversal: """Reject aliases that navigate outside apm_modules.""" def test_single_dot_rejected(self): - with pytest.raises( - ValueError, match="Aliases can only contain letters, numbers, dots, underscores, and hyphens" - ): + with pytest.raises(ValueError, match="reserved directory names"): parse_alias_override(".") def test_double_dot_rejected(self): - with pytest.raises( - ValueError, match="Aliases can only contain letters, numbers, dots, underscores, and hyphens" - ): + 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" + 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" + 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" + 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" + 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" + ValueError, + match="Aliases can only contain letters, numbers, dots, underscores, and hyphens", ): parse_alias_override("%252e%252e") @@ -61,6 +71,12 @@ def test_simple_name(self): 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" @@ -92,8 +108,8 @@ class TestInstallPhaseSymlinkEscape: 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; - the install-phase code invokes it at download.py:65 and integrate.py:622 for - every aliased dependency. + both install phases delegate to the materialization owner, which invokes + it for every aliased dependency. """ def test_symlink_escape_is_blocked(self, tmp_path): diff --git a/tests/unit/test_registry_entry_alias_traversal.py b/tests/unit/test_registry_entry_alias_traversal.py index fd59f61f5a..31923be87c 100644 --- a/tests/unit/test_registry_entry_alias_traversal.py +++ b/tests/unit/test_registry_entry_alias_traversal.py @@ -1,18 +1,4 @@ -"""Regression traps for the object-form registry alias validation. - -``parse_registry_object_entry`` (registry_entry.py) validates the ``alias`` -field in two layers: - -1. ``_ALIAS_PATTERN`` (registry_entry.py:81) -- rejects any character outside - letters / digits / dot / underscore / hyphen. This blocks ``/`` so most - traversal payloads never reach the next layer. -2. ``validate_path_segments`` (registry_entry.py:86) -- the defense-in-depth - containment/segment check that catches ``..`` payloads the regex permits - (dashes and dots are legal in an alias). - -These tests regression-trap both layers so the object-form registry parser -cannot silently install at an escaping alias path. -""" +"""Registry aliases delegate character and reserved-name checks to the shared parser.""" from __future__ import annotations @@ -51,14 +37,13 @@ def test_dotdot_alias_rejected(self): {"id": "org/pkg", "version": "v1", "alias": ".."}, ) - def test_nested_dotdot_alias_rejected(self): - """A dotted lookup-style alias resolves to a traversal in - validate_path_segments when the regex allows '..' segments.""" + 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, PathTraversalError)): + with pytest.raises(ValueError, match="reserved directory names"): parse_registry_object_entry( cls, - {"id": "org/pkg", "version": "v1", "alias": "pkg/.."}, + {"id": "org/pkg", "version": "v1", "alias": "."}, ) def test_encoded_dotdot_alias_rejected(self): From 3bad638a682c9a23e1c9126d8de6bf93e4479ceb Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Tue, 8 Sep 2026 14:25:22 +0200 Subject: [PATCH 25/34] fix(alias): preserve normalized roots and legacy cache preflight Address round-two architecture/security and lifecycle findings. Compare both roots through path_security, give safe alias recovery guidance, and preserve existing legacy-plugin validation before alias-aware resolution can normalize cached files. Main passes the legacy missing-metadata cases; added preflight keeps that behavior. Windows prefix and legacy-preflight mutation controls fail with guards removed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../checks/contracts_test_taxonomy.py | 9 +++++- src/apm_cli/install/legacy_plugin_compat.py | 22 ++++++++++---- src/apm_cli/install/phases/resolve.py | 11 ++++++- .../models/dependency/materialization.py | 11 +++++-- .../test_architecture_dependency_reference.py | 7 ++++- .../test_required_lifecycle_state_machine.py | 10 +++++-- tests/unit/test_alias_traversal.py | 30 +++++++++++++++++++ 7 files changed, 88 insertions(+), 12 deletions(-) diff --git a/scripts/architecture_linter/checks/contracts_test_taxonomy.py b/scripts/architecture_linter/checks/contracts_test_taxonomy.py index 195982fe44..75a1995288 100644 --- a/scripts/architecture_linter/checks/contracts_test_taxonomy.py +++ b/scripts/architecture_linter/checks/contracts_test_taxonomy.py @@ -481,7 +481,7 @@ def check_dependency_identity(provider: FactsProvider) -> tuple[Violation, ...]: "alias = parse_alias_override(dependency.alias)", "if alias is not None:", "resolved = ensure_path_within(result, apm_modules_dir)", - "if resolved == apm_modules_dir.resolve():", + "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": ( @@ -490,6 +490,13 @@ def check_dependency_identity(provider: FactsProvider) -> tuple[Violation, ...]: "src/apm_cli/install/phases/integrate.py": ( "install_path = dep_ref.get_install_path(apm_modules_dir)", ), + _RESOLVE_PHASE: ( + "if destination.exists():", + "validate_cached_legacy_plugin(", + ), + "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) 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/resolve.py b/src/apm_cli/install/phases/resolve.py index fdb0735d35..8b0db7ba3d 100644 --- a/src/apm_cli/install/phases/resolve.py +++ b/src/apm_cli/install/phases/resolve.py @@ -88,13 +88,22 @@ def _prepare_existing_materialization_paths( for dependency in dependencies: if dependency.is_marketplace: continue - _materialization.prepare_materialization_path( + destination = _materialization.prepare_materialization_path( dependency, apm_modules_dir, staging_session, reader=materialization_reader, on_migrate=on_migrate, ) + if destination.exists(): + from apm_cli.install.legacy_plugin_compat import validate_cached_legacy_plugin + + validate_cached_legacy_plugin( + destination, + dependency.get_unique_key(), + lockfile=existing_lockfile, + fetched_this_run=False, + ) def _materialization_migration_logger( diff --git a/src/apm_cli/models/dependency/materialization.py b/src/apm_cli/models/dependency/materialization.py index 5a7eaf47a0..7b060656f5 100644 --- a/src/apm_cli/models/dependency/materialization.py +++ b/src/apm_cli/models/dependency/materialization.py @@ -117,8 +117,15 @@ def build_materialization_path( alias = parse_alias_override(dependency.alias) if alias is not None: result = apm_modules_dir / alias - resolved = ensure_path_within(result, apm_modules_dir) - if resolved == apm_modules_dir.resolve(): + 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." diff --git a/tests/integration/test_architecture_dependency_reference.py b/tests/integration/test_architecture_dependency_reference.py index b7abb17ee7..7c95483a23 100644 --- a/tests/integration/test_architecture_dependency_reference.py +++ b/tests/integration/test_architecture_dependency_reference.py @@ -68,7 +68,7 @@ def test_alias_consumers_share_validation_and_materialization() -> None: ), ( "src/apm_cli/models/dependency/materialization.py", - "if resolved == apm_modules_dir.resolve():", + "if resolved == ensure_path_within(apm_modules_dir, apm_modules_dir):", "if False:", ), ( @@ -81,6 +81,11 @@ def test_alias_consumers_share_validation_and_materialization() -> None: "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", + "if destination.exists():", + "if False:", + ), ], ) def test_alias_owner_guard_rejects_bypass(path: str, before: str, after: str) -> None: diff --git a/tests/integration/test_required_lifecycle_state_machine.py b/tests/integration/test_required_lifecycle_state_machine.py index 3eaf11ea44..0bbcf10a8c 100644 --- a/tests/integration/test_required_lifecycle_state_machine.py +++ b/tests/integration/test_required_lifecycle_state_machine.py @@ -1619,8 +1619,12 @@ def test_required_invalid_receiptless_legacy_cache_fails_with_recovery( 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 @@ -1630,6 +1634,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() diff --git a/tests/unit/test_alias_traversal.py b/tests/unit/test_alias_traversal.py index 1bf123bcca..5eeda9be4e 100644 --- a/tests/unit/test_alias_traversal.py +++ b/tests/unit/test_alias_traversal.py @@ -1,5 +1,7 @@ """Unit tests for traversal alias rejection (alias-poisoning fix).""" +from pathlib import Path + import pytest from apm_cli.models.dependency import DependencyReference @@ -134,3 +136,31 @@ def test_symlink_escape_is_blocked(self, tmp_path): # 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) From ae3099de229141401b770af29c32dc0f63510f9b Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Tue, 8 Sep 2026 14:59:51 +0200 Subject: [PATCH 26/34] fix(alias): preserve remote source anchors and cache replacement Keep authenticated remote coordinates separate from flat aliases. Inject the existing read-only legacy cache admission at actual reuse after canonical fetch decisions, not preparation; retain same-ref failure and transactional replacement. Extend real resolver and CLI lifecycle contracts with aliased remote siblings and invalid-cache ref changes. Mutation controls detect both source-anchor regression and misplaced or missing admission. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../owners/contracts-tooling.json | 5 +- .../checks/contracts_test_taxonomy.py | 22 ++++++++- src/apm_cli/deps/apm_resolver.py | 33 +++++++++---- src/apm_cli/install/phases/resolve.py | 18 ++++---- .../test_architecture_dependency_reference.py | 18 +++++++- .../test_required_lifecycle_state_machine.py | 46 ++++++++++++++----- tests/test_apm_resolver.py | 44 ++++++++++++------ 7 files changed, 138 insertions(+), 48 deletions(-) diff --git a/.apm/architecture/owners/contracts-tooling.json b/.apm/architecture/owners/contracts-tooling.json index a898d993bb..91202b19db 100644 --- a/.apm/architecture/owners/contracts-tooling.json +++ b/.apm/architecture/owners/contracts-tooling.json @@ -4,12 +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 + object_fields.py (alias validation) + 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/object_fields.py", - "src/apm_cli/models/dependency/reference.py" + "src/apm_cli/models/dependency/reference.py", + "src/apm_cli/deps/apm_resolver.py" ], "guards": ["contracts-tooling-dependency-identity", "contracts-tooling-policy-identity"] }, diff --git a/scripts/architecture_linter/checks/contracts_test_taxonomy.py b/scripts/architecture_linter/checks/contracts_test_taxonomy.py index 75a1995288..9c90a41360 100644 --- a/scripts/architecture_linter/checks/contracts_test_taxonomy.py +++ b/scripts/architecture_linter/checks/contracts_test_taxonomy.py @@ -491,8 +491,15 @@ def check_dependency_identity(provider: FactsProvider) -> tuple[Violation, ...]: "install_path = dep_ref.get_install_path(apm_modules_dir)", ), _RESOLVE_PHASE: ( - "if destination.exists():", - "validate_cached_legacy_plugin(", + " 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(", @@ -501,9 +508,20 @@ def check_dependency_identity(provider: FactsProvider) -> tuple[Violation, ...]: 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( 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/install/phases/resolve.py b/src/apm_cli/install/phases/resolve.py index 8b0db7ba3d..40a9fbb6e1 100644 --- a/src/apm_cli/install/phases/resolve.py +++ b/src/apm_cli/install/phases/resolve.py @@ -88,22 +88,13 @@ def _prepare_existing_materialization_paths( for dependency in dependencies: if dependency.is_marketplace: continue - destination = _materialization.prepare_materialization_path( + _materialization.prepare_materialization_path( dependency, apm_modules_dir, staging_session, reader=materialization_reader, on_migrate=on_migrate, ) - if destination.exists(): - from apm_cli.install.legacy_plugin_compat import validate_cached_legacy_plugin - - validate_cached_legacy_plugin( - destination, - dependency.get_unique_key(), - lockfile=existing_lockfile, - fetched_this_run=False, - ) def _materialization_migration_logger( @@ -747,6 +738,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, @@ -761,6 +754,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/tests/integration/test_architecture_dependency_reference.py b/tests/integration/test_architecture_dependency_reference.py index 7c95483a23..33bae9ccd1 100644 --- a/tests/integration/test_architecture_dependency_reference.py +++ b/tests/integration/test_architecture_dependency_reference.py @@ -83,9 +83,25 @@ def test_alias_consumers_share_validation_and_materialization() -> None: ), ( "src/apm_cli/install/phases/resolve.py", - "if destination.exists():", + "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: diff --git a/tests/integration/test_required_lifecycle_state_machine.py b/tests/integration/test_required_lifecycle_state_machine.py index 0bbcf10a8c..9123b6b2f0 100644 --- a/tests/integration/test_required_lifecycle_state_machine.py +++ b/tests/integration/test_required_lifecycle_state_machine.py @@ -1500,24 +1500,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, @@ -1600,6 +1608,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) @@ -1616,6 +1628,18 @@ 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()) 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 From 0858443a9930c996d4aaa2f63d4f27348f2263ab Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Wed, 9 Sep 2026 11:19:32 +0200 Subject: [PATCH 27/34] docs(spec): preserve current baseline in inactive corrective draft Retain main req-pl-018 and Section 9.2, reconcile the 123-requirement inventory, and make human ratification and publication limits explicit. Preserve all original runtime pending linked successors. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../manifests/openapm-v0.1.requirements.yml | 5 + .../manifests/openapm-v0.2.requirements.yml | 27 +- docs/src/content/docs/specs/openapm-v0.1.md | 183 ++++++++++--- docs/src/content/docs/specs/openapm-v0.2.md | 253 +++++++++++++++--- 4 files changed, 379 insertions(+), 89 deletions(-) diff --git a/docs/public/specs/manifests/openapm-v0.1.requirements.yml b/docs/public/specs/manifests/openapm-v0.1.requirements.yml index 8e27cf8939..c6f7844c56 100644 --- a/docs/public/specs/manifests/openapm-v0.1.requirements.yml +++ b/docs/public/specs/manifests/openapm-v0.1.requirements.yml @@ -292,6 +292,11 @@ requirements: 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" diff --git a/docs/public/specs/manifests/openapm-v0.2.requirements.yml b/docs/public/specs/manifests/openapm-v0.2.requirements.yml index b5f7ed88a0..69844cd7be 100644 --- a/docs/public/specs/manifests/openapm-v0.2.requirements.yml +++ b/docs/public/specs/manifests/openapm-v0.2.requirements.yml @@ -1,23 +1,24 @@ # OpenAPM v0.2.0 -- informative requirements manifest (draft). # -# Generated initially by scripts/bootstrap_requirements.py from -# Appendix C of the previous minor, then maintained by hand on every -# spec edit. The orphan_check.py CI gate enforces that this file -# stays an ID/keyword/section/class projection of the HTML anchors in the spec +# Reconciled from the exact current-main previous-minor inventory and +# the corrective draft, then maintained with every spec edit. This file +# is an ID/keyword/section/class projection of the HTML anchors in the spec # body (the canonical source); Appendix C is a reader-aid derived # from the same anchors. # -# Do NOT edit this file alone. The three-step ritual for adding a -# normative requirement is: +# 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). -# CI fails at any incomplete step. +# The combined local-source and audit successor owns step 3 and the complete +# executable assessment. This foundation is inactive; it does not switch the +# active v0.1 selector or claim that draft bindings or runtime checks pass. # -# Informative CI input; Section 12.6's normative wire-format reservation +# 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. @@ -88,8 +89,7 @@ requirements: keyword: MUST section: "4.3.5" conformance_class: consumer - fixture: tests/fixtures/spec-conformance/manifest/valid-local-parent.yml - notes: "Clauses (a)-(d) bind source anchoring, user-scope admission, remote-repository containment, and local-content symlinks in test_local_path_reqs.py. Remote _local/ coordinates do not establish local provenance. These bindings assess v0.2.0, not the previous minor's blanket project-root refusal." + 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" @@ -229,7 +229,7 @@ requirements: 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. test_audit_current_intent_contract.py preserves the original 29 replay/current-intent cases and adds explicit-only selection, historical native ownership refusal, and narrowly expected-failing internal-link replay companions; its separate invalid-owner case binds req-pl-016." + 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" @@ -300,6 +300,11 @@ requirements: 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" diff --git a/docs/src/content/docs/specs/openapm-v0.1.md b/docs/src/content/docs/specs/openapm-v0.1.md index 7822f4c1be..94dab29d33 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 **121 normative statements** indexed in +- OpenAPM v0.1 carries **122 normative statements (117 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 @@ -1407,9 +1407,9 @@ The `dependencies` policy block governs APM dependency declarations. | Field | Semantic | |------------------------|-------------------------------------------------------------------------------------------| -| `allow` | List of patterns matched against `/`. Tri-state (see [Section 6.5](#65-allow-list--deny-list-tri-state-semantics)). | -| `deny` | Always wins over `allow`. | -| `require` | Packages every consumer manifest must include. | +| `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 every consumer manifest must include; 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. | @@ -1431,6 +1431,74 @@ 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 v0.1 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 must +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 @@ -1457,9 +1525,10 @@ directories that are not recorded in `apm.lock.yaml`. `directories` names the managed primitive target trees to scan, `action` selects the response (`ignore` | `warn` | `deny`), and `exclude` is a glob allow-list of workspace paths to suppress from the report. Its glob -patterns are matched with the same pattern semantics as the policy -allow-list and deny-list fields (see -[Section 6.5](#65-allow-list--deny-list-tri-state-semantics)). +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, @@ -1538,9 +1607,9 @@ merge a policy chain per the following table: |---------------------------------------|------------------------------------------------------------------------| | `enforcement` | Stricter wins (`block` > `warn` > `off`). | | `fetch_failure` | Child overrides if set. | -| `*.allow` lists | Set intersection. `null` is transparent. | -| `*.deny` lists | Union, deduplicated, parent order preserved. | -| `*.require` lists | Union, deduplicated, parent order preserved. | +| `*.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`). | @@ -1551,6 +1620,12 @@ merge a policy chain per the following table: | `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 @@ -1562,6 +1637,17 @@ transparent during merge; (b) explicit empty list `[]` means 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 @@ -1684,7 +1770,7 @@ This section's normative statements are: [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-017](#req-pl-017), [req-pl-018](#req-pl-018). --- @@ -1782,13 +1868,20 @@ safety boundary, not a wire artifact. It consists of: 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 consumer MAY case-fold paths for a host it documents as + 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. 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. + 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 @@ -2785,6 +2878,13 @@ OpenAPM follows the semver discipline at the document level: 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): @@ -3067,7 +3167,19 @@ 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. +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 must 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) @@ -3108,7 +3220,7 @@ every stored hash, foreclosing algorithm-ambiguity attacks. | 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 | -| 8 | Policy bypass via crafted manifest | [req-pl-002](#req-pl-002), [req-pl-009](#req-pl-009), [req-pl-010](#req-pl-010) | Governance-only | +| 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 | @@ -3317,6 +3429,15 @@ conformance statement identifying: 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 v0.1 this + information is a named prose section; a machine-readable carrier is + reserved in [Section 12.6](#126-machine-readable-conformance-manifest-reserved-for-v02). ### 11.3 Enumerated requirements by class @@ -3409,7 +3530,7 @@ v0.2 will formalise the surrounding HTTP wire envelope. [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-017](#req-pl-017), [req-pl-018](#req-pl-018). ### 11.4 Worked conformance examples (informative) @@ -3782,6 +3903,7 @@ renumbering of conformance classes. | [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 | @@ -3839,32 +3961,12 @@ 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: 121** (116 MUST, 5 SHOULD). +**Total normative statements: 122** (117 MUST, 5 SHOULD). --- ## Appendix D. Revision history - -### Corrective v0.2.0 preparation notice (informative) - -A separate [OpenAPM v0.2.0 draft](../openapm-v020/) is being prepared -for the local-source provenance/anchoring and audit current-intent/read-only -corrections. Its changed req-mf-016 and new audit obligation apply only -to that separately versioned contract. This notice does not modify this -minor's operative requirements, requirements manifest, schemas, or -conformance interpretation. This minor remains available and supported -indefinitely; no removal date is set. - -Prepared on 2026-09-06; this is not an announcement or publication date. -The publication owner will record the actual announcement and review/ -ratification evidence when publication occurs. Section 9.5's 90-day -minimum is between announcement and removal, not between announcement -and parallel publication. No migration exception is claimed. The -maintainer waived only the public-comment period for the corrective -publication; this notice does not assert completed review or ratification. - - | Version | Date | Changes | |---------|------------|----------------------------------------------------------| | 0.1 | 2026-05-10 | Initial editor's Working Draft. | @@ -3908,6 +4010,7 @@ publication; this notice does not assert completed review or ratification. | 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). diff --git a/docs/src/content/docs/specs/openapm-v0.2.md b/docs/src/content/docs/specs/openapm-v0.2.md index ed854d71e5..fe26afb0ab 100644 --- a/docs/src/content/docs/specs/openapm-v0.2.md +++ b/docs/src/content/docs/specs/openapm-v0.2.md @@ -11,9 +11,15 @@ OpenAPM v0.2.0 is the corrective specification of the APM package format, manife ## Status of This Document This document is an **editor's Working Draft** of OpenAPM, exact revision -**v0.2.0**, prepared on **2026-09-06**. This is a preparation date, not an -announcement, publication, or ratification date. Review and ratification -remain pending. Citing this draft as a ratified specification is inappropriate. +**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. +The combined local-source and audit successor owns the complete executable +assessment and bindings against the exact reconciled draft. This foundation +does not select that assessment, activate runtime changes, or record an +announcement, publication, or ratification date. The existing v0.1 remains +active. Qualifying human review and ratification remain pending; citing this +draft as a ratified specification is inappropriate. This bounded corrective minor changes only local-source provenance and anchoring ([req-mf-016](#req-mf-016)) and the current-intent, read-only @@ -145,7 +151,7 @@ between the companion corpus and the implementation. ### 1.3 Document conventions -- This revision carries **122 normative statements** indexed in +- This revision 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 @@ -704,6 +710,9 @@ 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 @@ -1566,9 +1575,9 @@ The `dependencies` policy block governs APM dependency declarations. | Field | Semantic | |------------------------|-------------------------------------------------------------------------------------------| -| `allow` | List of patterns matched against `/`. Tri-state (see [Section 6.5](#65-allow-list--deny-list-tri-state-semantics)). | -| `deny` | Always wins over `allow`. | -| `require` | Packages every consumer manifest must include. | +| `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 every consumer manifest must include; 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. | @@ -1590,6 +1599,74 @@ 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 must +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 @@ -1616,9 +1693,10 @@ directories that are not recorded in `apm.lock.yaml`. `directories` names the managed primitive target trees to scan, `action` selects the response (`ignore` | `warn` | `deny`), and `exclude` is a glob allow-list of workspace paths to suppress from the report. Its glob -patterns are matched with the same pattern semantics as the policy -allow-list and deny-list fields (see -[Section 6.5](#65-allow-list--deny-list-tri-state-semantics)). +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, @@ -1697,9 +1775,9 @@ merge a policy chain per the following table: |---------------------------------------|------------------------------------------------------------------------| | `enforcement` | Stricter wins (`block` > `warn` > `off`). | | `fetch_failure` | Child overrides if set. | -| `*.allow` lists | Set intersection. `null` is transparent. | -| `*.deny` lists | Union, deduplicated, parent order preserved. | -| `*.require` lists | Union, deduplicated, parent order preserved. | +| `*.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`). | @@ -1710,6 +1788,12 @@ merge a policy chain per the following table: | `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 @@ -1721,6 +1805,17 @@ transparent during merge; (b) explicit empty list `[]` means 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 @@ -1848,7 +1943,7 @@ This section's normative statements are: [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-017](#req-pl-017), [req-pl-018](#req-pl-018). --- @@ -1941,13 +2036,20 @@ safety boundary, not a wire artifact. It consists of: 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 consumer MAY case-fold paths for a host it documents as + 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. 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. + 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 @@ -2950,6 +3052,13 @@ OpenAPM follows the semver discipline at the document level: 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): @@ -3230,7 +3339,19 @@ 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. +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 must 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) @@ -3271,7 +3392,7 @@ every stored hash, foreclosing algorithm-ambiguity attacks. | 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); 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) | Governance-only | +| 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 | @@ -3488,6 +3609,41 @@ conformance statement identifying: 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. The combined local-source and audit +successor is responsible for complete executable bindings, fresh assessment, +and exact artifact/manifest fingerprints before any implementation claim. +Static references and passing 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 @@ -3581,7 +3737,7 @@ the surrounding HTTP wire envelope remains reserved for a future revision. [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-017](#req-pl-017), [req-pl-018](#req-pl-018). ### 11.4 Worked conformance examples (informative) @@ -3804,9 +3960,12 @@ 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 uses to detect silent drift between 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. Implementations MAY consume it in this revision but +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 @@ -3986,6 +4145,7 @@ the existing conformance classes or disabling [req-rg-001](#req-rg-001). | [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 | @@ -4043,7 +4203,7 @@ the existing conformance classes or disabling [req-rg-001](#req-rg-001). | [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). The [req-mf-016](#req-mf-016) consumer entry covers source anchoring, user-scope admission, remote-repository containment, and local @@ -4055,7 +4215,7 @@ one indexed requirement. ## Appendix D. Revision history -### v0.2.0 corrective revision (prepared 2026-09-06; not published) +### 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) @@ -4070,32 +4230,48 @@ expected output, comparison of prior target claims, and read-only replay with unsupported native writers refused before invocation. Section 8.4 cross-references that selection order; [req-pl-014](#req-pl-014) distinguishes default-mode advisory drift from CI/conformance failures. -Statement count: **121 -> 122** (117 MUST, 5 SHOULD). No other -requirement identifier is added, removed, or renumbered. +Statement count from the current-main baseline: **122 -> 123** +(118 MUST, 5 SHOULD). The complete previous-minor 0.1.40 amendment, +including [req-pl-018](#req-pl-018), its related identity, policy, +disclosure, and security text, and Section 9.2's deterministic-evaluation +allowance, is inherited rather than introduced by this correction. +No other requirement identifier is added, removed, or renumbered. **Classification and preservation.** These are substantive changes to the conformance contract under Sections 9.1, 9.2, and 9.4, not same-minor errata. The previous minor's operative specification, requirements -manifest, schemas, and conformance interpretation are retained from -`0ae9a78c6129e41caf87cb75130f9006b31ed98d`; only an explicitly delimited -informative announcement is added to its Appendix D. That previous +manifest, schemas, and conformance interpretation are retained from exact +current main `f8df1b751efc30b32dc01b125616b51f777b4c81`. Its artifact +and manifest are retained byte-for-byte, without a new announcement. +That previous minor remains 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 needed or claimed. An admitted local sibling outside the project root can satisfy this revision while violating the old blanket rule. Neither this correction -nor the new test bindings establishes that the reference CLI ever +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 for this corrective -publication. Qualifying non-author review, implementation evidence, and -ratification remain required. This preparation record does not assert -that any of them is complete. The publication owner records actual -announcement/publication dates and approval evidence at publication; -2026-09-06 above is only the preparation date. Section 9 remains unchanged. +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 required; the AI advisory panel does not +supply those human approvals. This foundation record asserts none of them +complete and records no announcement, publication, or ratification date. +The publication owner supplies the actual record later. Section 9 is +retained unchanged from current main, not from the stale original branch. + +The original #2820 is retained as the inactive corrective-spec foundation, +not the implementation-conformance change. Its combined local-source and +audit successor owns the complete executable assessment and bindings, +reanchored to the exact draft and current-main preservation baseline. +Existing v0.1 remains active; this foundation does not activate a new +assessment selector, runtime behavior, normative publication, or latest alias. No previously reserved workspace, nesting, attestation, HTTP wire, internationalization, range-widening, withdrawal, or default-frozen feature @@ -4151,6 +4327,7 @@ above controls this revision. | 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). From 9167a99973d669d9080d68704911d89d487c7f94 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Wed, 9 Sep 2026 11:31:39 +0200 Subject: [PATCH 28/34] fix(alias): preserve placement and current ref replay after install Continue the existing PR #2901 CI recovery without resetting its run. Preserve the recovered implementation and review folds: durable alias projection, contained alias scanning, specification conformance, and current-remote ref observations after successful checkout. Original contributor and follow-up commits remain in the lineage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../owners/contracts-tooling.json | 2 +- CHANGELOG.md | 2 +- CONFORMANCE.json | 59 ++++++- CONFORMANCE.md | 3 +- .../manifests/openapm-v0.1.requirements.yml | 4 + .../specs/schemas/lockfile-v0.1.schema.json | 6 + .../specs/schemas/manifest-v0.1.schema.json | 6 +- docs/src/content/docs/reference/cli/cache.md | 6 + docs/src/content/docs/reference/cli/prune.md | 2 +- .../content/docs/reference/lockfile-spec.md | 1 + docs/src/content/docs/specs/openapm-v0.1.md | 47 +++++- .../content/docs/troubleshooting/migration.md | 7 + .../.apm/skills/apm-usage/dependencies.md | 5 + .../checks/contracts_test_taxonomy.py | 7 + .../checks/transport_network_and_runtime.py | 20 +++ src/apm_cli/cache/git_cache.py | 28 ++++ src/apm_cli/commands/deps/_utils.py | 13 +- src/apm_cli/deps/github_downloader.py | 26 ++- src/apm_cli/deps/lockfile.py | 16 ++ src/apm_cli/deps/tiered_ref_resolver.py | 31 +++- src/apm_cli/install/helpers/ref_reuse.py | 9 + src/apm_cli/install/phases/resolve.py | 11 +- .../test_architecture_dependency_reference.py | 7 + .../test_architecture_owner_rule_mutations.py | 19 +++ ...test_hook_js_sidecar_lifecycle_contract.py | 17 ++ .../test_required_lifecycle_state_machine.py | 4 +- tests/spec_conformance/test_alias_reqs.py | 157 ++++++++++++++++++ tests/unit/cache/test_git_cache.py | 43 +++++ .../deps/test_github_downloader_phase3.py | 31 ++++ .../deps/test_lockfile_consumer_contract.py | 4 + .../deps/test_lockfile_field_properties.py | 34 ++-- tests/unit/deps/test_tiered_ref_resolver.py | 39 ++++- tests/unit/test_command_helpers.py | 8 +- tests/unit/test_deps_utils.py | 26 ++- 34 files changed, 631 insertions(+), 69 deletions(-) create mode 100644 tests/spec_conformance/test_alias_reqs.py diff --git a/.apm/architecture/owners/contracts-tooling.json b/.apm/architecture/owners/contracts-tooling.json index 91202b19db..7b48363743 100644 --- a/.apm/architecture/owners/contracts-tooling.json +++ b/.apm/architecture/owners/contracts-tooling.json @@ -62,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 75cd4f8291..f97543d240 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security -- Dependency aliases now reject `.` and `..` and require destinations strictly inside `apm_modules/` after symlink resolution; [rename rejected aliases and review reinstall artifacts](https://microsoft.github.io/apm/troubleshooting/migration/#rejected-dependency-aliases). - by @Danvs60 (#2901) +- 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/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/schemas/lockfile-v0.1.schema.json b/docs/public/specs/schemas/lockfile-v0.1.schema.json index 420700abc5..07e2753106 100644 --- a/docs/public/specs/schemas/lockfile-v0.1.schema.json +++ b/docs/public/specs/schemas/lockfile-v0.1.schema.json @@ -36,6 +36,12 @@ "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, diff --git a/docs/public/specs/schemas/manifest-v0.1.schema.json b/docs/public/specs/schemas/manifest-v0.1.schema.json index 642db658f8..e04ea52961 100644 --- a/docs/public/specs/schemas/manifest-v0.1.schema.json +++ b/docs/public/specs/schemas/manifest-v0.1.schema.json @@ -105,7 +105,11 @@ "ref": { "type": "string" }, "prerelease": { "type": "boolean" }, "path": { "type": "string" }, - "alias": { "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-]*$": {} }, 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..f00b74d793 100644 --- a/docs/src/content/docs/reference/lockfile-spec.md +++ b/docs/src/content/docs/reference/lockfile-spec.md @@ -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/specs/openapm-v0.1.md b/docs/src/content/docs/specs/openapm-v0.1.md index 94dab29d33..487b17cfeb 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), @@ -3862,6 +3899,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 +3999,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 +4049,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. 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/troubleshooting/migration.md b/docs/src/content/docs/troubleshooting/migration.md index 849547fd5a..83aa00c02b 100644 --- a/docs/src/content/docs/troubleshooting/migration.md +++ b/docs/src/content/docs/troubleshooting/migration.md @@ -122,6 +122,13 @@ under `apm_modules/`, including through symlinks. Safe dotted names such as 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 1cfc881e7f..7ad7616691 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/dependencies.md +++ b/packages/apm-guide/.apm/skills/apm-usage/dependencies.md @@ -179,6 +179,11 @@ instead so `@` remains reserved for git usernames and version syntax. | `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`. diff --git a/scripts/architecture_linter/checks/contracts_test_taxonomy.py b/scripts/architecture_linter/checks/contracts_test_taxonomy.py index 9c90a41360..bf3282cbda 100644 --- a/scripts/architecture_linter/checks/contracts_test_taxonomy.py +++ b/scripts/architecture_linter/checks/contracts_test_taxonomy.py @@ -470,6 +470,13 @@ def check_dependency_identity(provider: FactsProvider) -> tuple[Violation, ...]: 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"))', 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/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/phases/resolve.py b/src/apm_cli/install/phases/resolve.py index 40a9fbb6e1..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, diff --git a/tests/integration/test_architecture_dependency_reference.py b/tests/integration/test_architecture_dependency_reference.py index 33bae9ccd1..1632355c44 100644 --- a/tests/integration/test_architecture_dependency_reference.py +++ b/tests/integration/test_architecture_dependency_reference.py @@ -55,6 +55,13 @@ def test_alias_consumers_share_validation_and_materialization() -> None: @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", 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 9123b6b2f0..f4efa4247a 100644 --- a/tests/integration/test_required_lifecycle_state_machine.py +++ b/tests/integration/test_required_lifecycle_state_machine.py @@ -1732,8 +1732,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/spec_conformance/test_alias_reqs.py b/tests/spec_conformance/test_alias_reqs.py new file mode 100644 index 0000000000..023da91a00 --- /dev/null +++ b/tests/spec_conformance/test_alias_reqs.py @@ -0,0 +1,157 @@ +"""Dependency alias validation, containment, and durable replay conformance.""" + +from pathlib import Path + +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_yaml_fixture, validate_against + +pytestmark = pytest.mark.component + + +@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.schema.json", manifest, manifest_valid), + ("lockfile-v0.1.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/unit/cache/test_git_cache.py b/tests/unit/cache/test_git_cache.py index 5b8941678f..25a9a6e43e 100644 --- a/tests/unit/cache/test_git_cache.py +++ b/tests/unit/cache/test_git_cache.py @@ -12,6 +12,49 @@ from apm_cli.cache.git_cache import CachePruneError, GitCache +@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") + 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 def test_prune_reports_only_completed_deletions( tmp_path: Path, monkeypatch: pytest.MonkeyPatch 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_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 == [] From c5b4a860709c65a2ccd0bdae407bc52307581eff Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Wed, 9 Sep 2026 12:08:00 +0200 Subject: [PATCH 29/34] test(cache): exercise stale bare fallback under corrupt receipts The mutation gate showed the previous fixture passed even when receipt handling was removed because no bare-cache directory existed. Materialize the canonical shard directory so the stale-ref fallback is reachable and the regression fails without the receipt guard. Production code is unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/unit/cache/test_git_cache.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit/cache/test_git_cache.py b/tests/unit/cache/test_git_cache.py index 25a9a6e43e..e98a0d72fb 100644 --- a/tests/unit/cache/test_git_cache.py +++ b/tests/unit/cache/test_git_cache.py @@ -10,6 +10,7 @@ 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"]) @@ -50,6 +51,7 @@ def test_corrupt_remote_ref_receipt_does_not_revive_stale_bare_ref(tmp_path: Pat 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() From 58190d852693b04821d6a9d133d869ea3432a82f Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Wed, 9 Sep 2026 12:28:30 +0200 Subject: [PATCH 30/34] test(lockfile): declare absent alias in dev dependency fixtures Continue CI recovery2 after Linux shard2 exposed four generic Mock dependency references whose undeclared alias attribute became another Mock. Model the real unaliased DependencyReference default explicitly instead of weakening production alias validation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/unit/test_dev_dependencies.py | 1 + 1 file changed, 1 insertion(+) 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 From d22119c1d4a42c46f6ce2014ac8c9604ee6165a8 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Wed, 9 Sep 2026 19:47:09 +0200 Subject: [PATCH 31/34] docs(spec): reconcile corrective foundation with prospective alias amendment Carry the full 124-requirement union without activating v0.2 or replacing human Section 9.3 review. Preserve exact prospective v0.1 schemas and all local/audit boundaries, with assessment owned by #2919. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../manifests/openapm-v0.2.requirements.yml | 19 ++- docs/src/content/docs/specs/openapm-v0.2.md | 161 +++++++++++------- 2 files changed, 114 insertions(+), 66 deletions(-) diff --git a/docs/public/specs/manifests/openapm-v0.2.requirements.yml b/docs/public/specs/manifests/openapm-v0.2.requirements.yml index 69844cd7be..c19e4e412c 100644 --- a/docs/public/specs/manifests/openapm-v0.2.requirements.yml +++ b/docs/public/specs/manifests/openapm-v0.2.requirements.yml @@ -1,10 +1,9 @@ # OpenAPM v0.2.0 -- informative requirements manifest (draft). # -# Reconciled from the exact current-main previous-minor inventory and -# the corrective draft, then maintained with every spec edit. This file -# is an ID/keyword/section/class projection of the HTML anchors in the spec -# body (the canonical source); Appendix C is a reader-aid derived -# from the same anchors. +# Inherits the prospective #2901 inventory at exact commit +# d1dd63c6d1e4a4b0af95f490c111270d56a16bdd, 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: @@ -14,9 +13,9 @@ # 3. Add or extend a test under tests/spec_conformance/ with # @pytest.mark.req("req-XXX") (and optionally cite a fixture # path). -# The combined local-source and audit successor owns step 3 and the complete -# executable assessment. This foundation is inactive; it does not switch the -# active v0.1 selector or claim that draft bindings or runtime checks pass. +# #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 @@ -122,6 +121,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/src/content/docs/specs/openapm-v0.2.md b/docs/src/content/docs/specs/openapm-v0.2.md index fe26afb0ab..28818593b4 100644 --- a/docs/src/content/docs/specs/openapm-v0.2.md +++ b/docs/src/content/docs/specs/openapm-v0.2.md @@ -14,19 +14,21 @@ 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. -The combined local-source and audit successor owns the complete executable -assessment and bindings against the exact reconciled draft. This foundation -does not select that assessment, activate runtime changes, or record an -announcement, publication, or ratification date. The existing v0.1 remains -active. Qualifying human review and ratification remain pending; citing this -draft as a ratified specification is inappropriate. - -This bounded 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. This is a -review candidate, not a publication or ratification record. -Features previously reserved for v0.2 remain reserved for a future -revision; they are not activated by v0.2.0. +It inherits alias safety prospectively from +[microsoft/apm#2901](https://github.com/microsoft/apm/pull/2901) at exact commit +[`d1dd63c6d1e4a4b0af95f490c111270d56a16bdd`](https://github.com/microsoft/apm/commit/d1dd63c6d1e4a4b0af95f490c111270d56a16bdd), +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**. @@ -151,7 +153,7 @@ between the companion corpus and the implementation. ### 1.3 Document conventions -- This revision carries **123 normative statements (118 MUST, 5 SHOULD)** indexed in +- 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 @@ -561,9 +563,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 @@ -887,6 +920,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). @@ -945,6 +979,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. | @@ -3327,6 +3362,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 @@ -3391,7 +3429,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); 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); 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 | @@ -3621,10 +3659,12 @@ conformance statement identifying: **Foundation assessment status (informative).** This draft and its informative requirement inventory do not assert that the reference CLI -satisfies the requirements above. The combined local-source and audit -successor is responsible for complete executable bindings, fresh assessment, -and exact artifact/manifest fingerprints before any implementation claim. -Static references and passing schema checks are not runtime conformance. +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 @@ -3666,6 +3706,7 @@ These disclosures are not waivers of any normative obligation. [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), @@ -4103,6 +4144,7 @@ the existing conformance classes or disabling [req-rg-001](#req-rg-001). | [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 | @@ -4203,7 +4245,7 @@ the existing conformance classes or disabling [req-rg-001](#req-rg-001). | [req-cf-001](#req-cf-001) | MUST | 12.5 | consumer | | [req-cf-002](#req-cf-002) | MUST | 12.3 | consumer | -**Total normative statements: 123** (118 MUST, 5 SHOULD). +**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 @@ -4221,32 +4263,39 @@ 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). -Only those two contracts change behavior. The approved local-source -clauses replace [req-mf-016](#req-mf-016)'s previous blanket project-root -escape rejection with source-provenance admission, original declaring-source -anchors, remote-repository containment, and internal local-symlink rules. -[req-lk-023](#req-lk-023) adds current-intent precedence, source-derived -expected output, comparison of prior target claims, and read-only replay -with unsupported native writers refused before invocation. Section 8.4 -cross-references that selection order; [req-pl-014](#req-pl-014) -distinguishes default-mode advisory drift from CI/conformance failures. -Statement count from the current-main baseline: **122 -> 123** -(118 MUST, 5 SHOULD). The complete previous-minor 0.1.40 amendment, -including [req-pl-018](#req-pl-018), its related identity, policy, -disclosure, and security text, and Section 9.2's deterministic-evaluation -allowance, is inherited rather than introduced by this correction. -No other requirement identifier is added, removed, or renumbered. - -**Classification and preservation.** These are substantive changes to -the conformance contract under Sections 9.1, 9.2, and 9.4, not same-minor -errata. The previous minor's operative specification, requirements -manifest, schemas, and conformance interpretation are retained from exact -current main `f8df1b751efc30b32dc01b125616b51f777b4c81`. Its artifact -and manifest are retained byte-for-byte, without a new announcement. -That previous -minor remains available and supported indefinitely, with no removal date. +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 +[`d1dd63c6d1e4a4b0af95f490c111270d56a16bdd`](https://github.com/microsoft/apm/commit/d1dd63c6d1e4a4b0af95f490c111270d56a16bdd), +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. + +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, requirements manifest, and all four public +schemas match that exact prospective dependency byte-for-byte. 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 needed or claimed. +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 @@ -4260,18 +4309,14 @@ drafting of this bounded normative reconciliation and waived only the 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 required; the AI advisory panel does not -supply those human approvals. This foundation record asserts none of them -complete and records no announcement, publication, or ratification date. -The publication owner supplies the actual record later. Section 9 is -retained unchanged from current main, not from the stale original branch. - -The original #2820 is retained as the inactive corrective-spec foundation, -not the implementation-conformance change. Its combined local-source and -audit successor owns the complete executable assessment and bindings, -reanchored to the exact draft and current-main preservation baseline. -Existing v0.1 remains active; this foundation does not activate a new -assessment selector, runtime behavior, normative publication, or latest alias. +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 From 5a0d872b9dd63b78b08923eddfad0ed1f9b369b4 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Wed, 9 Sep 2026 20:08:29 +0200 Subject: [PATCH 32/34] fix(spec): preserve published alias schema identities Keep existing public v0.1 schemas byte-identical and select independent v0.1.41 schemas for alias validation. Accept the new exact manifest ID through the existing contract owner while retaining old-ID compatibility and fail-closed unknowns. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../schemas/lockfile-v0.1.41.schema.json | 86 ++++++++++++ .../specs/schemas/lockfile-v0.1.schema.json | 6 - .../schemas/manifest-v0.1.41.schema.json | 127 ++++++++++++++++++ .../specs/schemas/manifest-v0.1.schema.json | 6 +- .../content/docs/reference/lockfile-spec.md | 2 +- .../content/docs/reference/manifest-schema.md | 16 +-- docs/src/content/docs/specs/openapm-v0.1.md | 41 +++--- .../skills/apm-usage/package-authoring.md | 8 +- src/apm_cli/models/manifest_contract.py | 8 +- .../test_architecture_contract_guards.py | 6 +- tests/spec_conformance/test_alias_reqs.py | 42 +++++- tests/spec_conformance/test_lockfile_reqs.py | 34 ++--- tests/spec_conformance/test_manifest_reqs.py | 54 ++++---- .../spec_conformance/test_resolution_reqs.py | 2 +- 14 files changed, 345 insertions(+), 93 deletions(-) create mode 100644 docs/public/specs/schemas/lockfile-v0.1.41.schema.json create mode 100644 docs/public/specs/schemas/manifest-v0.1.41.schema.json 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/lockfile-v0.1.schema.json b/docs/public/specs/schemas/lockfile-v0.1.schema.json index 07e2753106..420700abc5 100644 --- a/docs/public/specs/schemas/lockfile-v0.1.schema.json +++ b/docs/public/specs/schemas/lockfile-v0.1.schema.json @@ -36,12 +36,6 @@ "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, 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/public/specs/schemas/manifest-v0.1.schema.json b/docs/public/specs/schemas/manifest-v0.1.schema.json index e04ea52961..642db658f8 100644 --- a/docs/public/specs/schemas/manifest-v0.1.schema.json +++ b/docs/public/specs/schemas/manifest-v0.1.schema.json @@ -105,11 +105,7 @@ "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." - }, + "alias": { "type": "string" }, "skills": { "type": "array", "items": { "type": "string" } } }, "patternProperties": { "^x-[a-z][a-z0-9-]*$": {} }, diff --git a/docs/src/content/docs/reference/lockfile-spec.md b/docs/src/content/docs/reference/lockfile-spec.md index f00b74d793..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 diff --git a/docs/src/content/docs/reference/manifest-schema.md b/docs/src/content/docs/reference/manifest-schema.md index 877d110c38..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). diff --git a/docs/src/content/docs/specs/openapm-v0.1.md b/docs/src/content/docs/specs/openapm-v0.1.md index 487b17cfeb..e40d407a79 100644 --- a/docs/src/content/docs/specs/openapm-v0.1.md +++ b/docs/src/content/docs/specs/openapm-v0.1.md @@ -3810,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 @@ -4049,7 +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. Sections 1.3, 4.9, 5.2, 11.3.2, and Appendix C updated. Statement count: 122 -> 123 (118 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/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/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/spec_conformance/test_alias_reqs.py b/tests/spec_conformance/test_alias_reqs.py index 023da91a00..b114049297 100644 --- a/tests/spec_conformance/test_alias_reqs.py +++ b/tests/spec_conformance/test_alias_reqs.py @@ -1,6 +1,7 @@ """Dependency alias validation, containment, and durable replay conformance.""" from pathlib import Path +from urllib.parse import urlparse import jsonschema import pytest @@ -9,11 +10,46 @@ 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_yaml_fixture, validate_against +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"] @@ -126,8 +162,8 @@ def test_alias_schemas_distinguish_input_from_canonical_output( lock = load_yaml_fixture("lockfile", "v2-with-registry.yml") lock["dependencies"][0]["alias"] = alias for schema, document, valid in ( - ("manifest-v0.1.schema.json", manifest, manifest_valid), - ("lockfile-v0.1.schema.json", lock, lock_valid), + ("manifest-v0.1.41.schema.json", manifest, manifest_valid), + ("lockfile-v0.1.41.schema.json", lock, lock_valid), ): if valid: validate_against(schema, document) 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 " From 0eac24f5a3d6a1c69cfe91970e222a7b33a8a1ee Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Wed, 9 Sep 2026 20:09:35 +0200 Subject: [PATCH 33/34] docs(changelog): name the inactive specification artifact Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4658915be9..2229d8b9d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Inactive OpenAPM v0.2.0 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) +- 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) From 9e5a0ebf6ea595be4de480402c6c4e15b95751b6 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Wed, 9 Sep 2026 20:16:25 +0200 Subject: [PATCH 34/34] docs(spec): reconcile reviewed inactive foundation and schema identities Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../manifests/openapm-v0.2.requirements.yml | 2 +- docs/src/content/docs/specs/openapm-v0.2.md | 136 ++++++++++++------ 2 files changed, 92 insertions(+), 46 deletions(-) diff --git a/docs/public/specs/manifests/openapm-v0.2.requirements.yml b/docs/public/specs/manifests/openapm-v0.2.requirements.yml index c19e4e412c..931f7429e6 100644 --- a/docs/public/specs/manifests/openapm-v0.2.requirements.yml +++ b/docs/public/specs/manifests/openapm-v0.2.requirements.yml @@ -1,7 +1,7 @@ # OpenAPM v0.2.0 -- informative requirements manifest (draft). # # Inherits the prospective #2901 inventory at exact commit -# d1dd63c6d1e4a4b0af95f490c111270d56a16bdd, not a published or ratified baseline. +# 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. # diff --git a/docs/src/content/docs/specs/openapm-v0.2.md b/docs/src/content/docs/specs/openapm-v0.2.md index 28818593b4..383650ccfe 100644 --- a/docs/src/content/docs/specs/openapm-v0.2.md +++ b/docs/src/content/docs/specs/openapm-v0.2.md @@ -1,6 +1,6 @@ --- -title: OpenAPM v0.2.0 -description: Normative specification for the Agent Package Manager (APM) format and conformance. +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 @@ -14,9 +14,9 @@ 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 prospectively from +It inherits alias safety and independently versioned schemas prospectively from [microsoft/apm#2901](https://github.com/microsoft/apm/pull/2901) at exact commit -[`d1dd63c6d1e4a4b0af95f490c111270d56a16bdd`](https://github.com/microsoft/apm/commit/d1dd63c6d1e4a4b0af95f490c111270d56a16bdd), +[`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 @@ -910,9 +910,11 @@ 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-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-006](#req-mf-006), [req-mf-007](#req-mf-007), +- 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), @@ -922,8 +924,9 @@ This section's normative statements are: [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). +- Consumer (SHOULD): [req-mf-004](#req-mf-004), limited to its existing + Consumer version-validation diagnostic clause. --- @@ -1476,12 +1479,21 @@ envelope. > 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, with `tree_sha256` providing collision-resistant -> integrity. A future revision will track `resolved_commit_sha256` +> 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 @@ -1612,7 +1624,7 @@ The `dependencies` policy block governs APM dependency declarations. |------------------------|-------------------------------------------------------------------------------------------| | `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 every consumer manifest must include; 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. | @@ -1692,7 +1704,7 @@ sharing a segment with one or more other characters, for example `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 must +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). @@ -3209,7 +3221,7 @@ acceptable name space. **[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-006](#req-sc-006)), and MUST NOT forward a credential +[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) @@ -3381,7 +3393,7 @@ 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 must enumerate the spellings +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 @@ -3467,6 +3479,18 @@ modes; and the registry HTTP wire envelope (alongside 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 -- @@ -3629,10 +3653,12 @@ Section-level conformance summaries [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 that restate the Appendix C rows for the section's -class. Appendix C is the canonical source of truth; on any -conflict between a section summary and Appendix C, Appendix C -wins. +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 @@ -3696,8 +3722,15 @@ These disclosures are not waivers of any normative obligation. [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), @@ -4029,11 +4062,14 @@ prepared under the docs site; draft availability does not imply ratification: | `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. The exact v0.2.0 content route is `/specs/openapm-v020/`. +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 @@ -4041,15 +4077,20 @@ ratification. ## Appendix A. Normative JSON Schemas (inline) -The machine-readable schemas backing this specification are -reused unchanged from the previous minor and are normative. This corrective -revision introduces no wire-schema change; schema identities do not follow -the specification's version number. +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 unchanged wire-schema `$id` values are: +The selected wire-schema `$id` values are: -- `https://microsoft.github.io/apm/specs/schemas/manifest-v0.1.schema.json` -- `https://microsoft.github.io/apm/specs/schemas/lockfile-v0.1.schema.json` +- `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 @@ -4057,18 +4098,20 @@ The informative requirements format separately reuses | 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 this revision; 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 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 @@ -4270,7 +4313,7 @@ clarifications. **Prospective dependency.** This draft inherits [microsoft/apm#2901](https://github.com/microsoft/apm/pull/2901) at exact commit -[`d1dd63c6d1e4a4b0af95f490c111270d56a16bdd`](https://github.com/microsoft/apm/commit/d1dd63c6d1e4a4b0af95f490c111270d56a16bdd), +[`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 @@ -4280,7 +4323,9 @@ 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. +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. @@ -4290,10 +4335,12 @@ allowance, remains inherited. No other identifier is added, removed, or renumber **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, requirements manifest, and all four public -schemas match that exact prospective dependency byte-for-byte. The import -establishes no publication or ratification. v0.1 remains active, available, -and supported indefinitely, with no removal date. +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. @@ -4320,8 +4367,7 @@ 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. Existing schema identities and independently versioned -companions are reused without alteration. +is activated. Independently versioned companions remain unchanged. ### Previous-minor history (informative)