Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
d68e0bd
fix(install): preserve local transitive anchors at user scope
danielmeppiel Sep 5, 2026
bb1acf4
Merge current main before publishing #2815
danielmeppiel Sep 5, 2026
e718811
test(architecture): register local scope admission in fixed inventory
danielmeppiel Sep 5, 2026
22024cb
Merge current main before local-path specification reconciliation
danielmeppiel Sep 6, 2026
c2f20a0
spec: reconcile trusted-local source anchoring (#2818)
danielmeppiel Sep 6, 2026
768a4e2
spec: clarify source routing and local replay limits
danielmeppiel Sep 6, 2026
c457d91
fix: require established declaring-source provenance
danielmeppiel Sep 6, 2026
0f0d4b0
docs(spec): prepare corrective minor and exact assessment harness
danielmeppiel Sep 6, 2026
75d0f74
Merge immutable declaring-source provenance correction
danielmeppiel Sep 6, 2026
3069210
test(spec): retain provenance mutation coverage without duplicate guard
danielmeppiel Sep 6, 2026
70b772e
fix(audit): replay canonical current target intent
danielmeppiel Sep 5, 2026
1d4ff88
fix(audit): keep native replay and discovery read-only
danielmeppiel Sep 6, 2026
68015fa
spec: complete corrective v0.2.0 review candidate
danielmeppiel Sep 6, 2026
4878a17
Merge current main for combined fix and corrective-spec validation
danielmeppiel Sep 6, 2026
a036f37
spec: fold corrective review clarifications and evidence limits
danielmeppiel Sep 6, 2026
26c04b8
Integrate bounded corrective-spec review folds
danielmeppiel Sep 6, 2026
f4110c8
test(spec): preserve review evidence and explicit replay limitations
danielmeppiel Sep 6, 2026
a099b69
docs(spec): distinguish original audit cases from review companions
danielmeppiel Sep 6, 2026
300a24a
fix(audit): keep startup and cold replay read-only
danielmeppiel Sep 6, 2026
f5064df
test(cli): preserve JSON guarantees without audit update notices
danielmeppiel Sep 6, 2026
7bb5701
Merge current main into PR 2820 repair baseline
danielmeppiel Sep 7, 2026
92833cf
fix(copilot): rebuild managed instruction aggregates after uninstall
danielmeppiel Sep 7, 2026
32a467c
Merge current main for production closeout
danielmeppiel Sep 7, 2026
216aca2
fix(lifecycle): fold aggregate, audit, and recovery review findings
danielmeppiel Sep 7, 2026
c3e3915
Merge remote-tracking branch 'origin/main' into danielmeppiel-phase-2…
danielmeppiel Sep 7, 2026
824b170
test(architecture): keep aggregate mutation in deterministic inventor…
danielmeppiel Sep 7, 2026
897e15a
fix(alias): reject path-traversing dependency aliases
Danvs60 Sep 7, 2026
38608f4
fix(alias): surface friendly error for traversal aliases
Danvs60 Sep 8, 2026
206d489
test(alias): prove install-phase symlink escape is blocked
Danvs60 Sep 8, 2026
9da1ecd
test(alias): trap ensure_path_within guards end-to-end at install tier
Danvs60 Sep 8, 2026
e845ebf
Refactor install path handling for dependencies
Danvs60 Sep 8, 2026
e2015db
fix(alias): centralize safe destinations without restricting sources
danielmeppiel Sep 8, 2026
1c9029a
Merge contributor follow-up while retaining canonical alias paths
danielmeppiel Sep 8, 2026
2893043
Merge main before alias compatibility validation
danielmeppiel Sep 8, 2026
3bad638
fix(alias): preserve normalized roots and legacy cache preflight
danielmeppiel Sep 8, 2026
ae3099d
fix(alias): preserve remote source anchors and cache replacement
danielmeppiel Sep 8, 2026
0858443
docs(spec): preserve current baseline in inactive corrective draft
danielmeppiel Sep 9, 2026
9167a99
fix(alias): preserve placement and current ref replay after install
danielmeppiel Sep 9, 2026
c5b4a86
test(cache): exercise stale bare fallback under corrupt receipts
danielmeppiel Sep 9, 2026
58190d8
test(lockfile): declare absent alias in dev dependency fixtures
danielmeppiel Sep 9, 2026
a8607b2
docs(spec): isolate inactive corrective foundation after runtime split
danielmeppiel Sep 9, 2026
d1dd63c
Merge current main into alias recovery candidate
danielmeppiel Sep 9, 2026
e6e3191
Merge prospective alias prerequisite for corrective spec reconciliation
danielmeppiel Sep 9, 2026
d22119c
docs(spec): reconcile corrective foundation with prospective alias am…
danielmeppiel Sep 9, 2026
5a0d872
fix(spec): preserve published alias schema identities
danielmeppiel Sep 9, 2026
0eac24f
docs(changelog): name the inactive specification artifact
danielmeppiel Sep 9, 2026
9929f4d
Merge versioned alias schemas without replacing published identities
danielmeppiel Sep 9, 2026
9e5a0eb
docs(spec): reconcile reviewed inactive foundation and schema identities
danielmeppiel Sep 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .apm/architecture/owners/install-deployment.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions docs/src/content/docs/reference/cli/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
:::
Expand Down
4 changes: 4 additions & 0 deletions packages/apm-guide/.apm/skills/apm-usage/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
13 changes: 7 additions & 6 deletions packages/apm-guide/.apm/skills/apm-usage/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.",
Expand Down
46 changes: 46 additions & 0 deletions scripts/architecture_linter/checks/install_request_and_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
_duplicate_definition_lines,
_facts_for,
_lines,
_name_calls_in,
_present,
_present_re,
_summary,
Expand All @@ -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"
Expand All @@ -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:
Expand Down
28 changes: 23 additions & 5 deletions src/apm_cli/install/package_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand Down
12 changes: 2 additions & 10 deletions src/apm_cli/install/phases/resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -363,14 +363,14 @@ 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,
_collect_insecure_dependency_infos,
_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)
Expand Down Expand Up @@ -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)
Expand Down
35 changes: 17 additions & 18 deletions src/apm_cli/install/sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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``
Expand Down
8 changes: 8 additions & 0 deletions tests/integration/test_architecture_owner_rule_mutations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading