fix(install): anchor transitive local_path deps on declaring package (#857)#1111
Merged
danielmeppiel merged 2 commits intomainfrom May 2, 2026
Merged
fix(install): anchor transitive local_path deps on declaring package (#857)#1111danielmeppiel merged 2 commits intomainfrom
danielmeppiel merged 2 commits intomainfrom
Conversation
A package's local_path references are now anchored on that package's own directory (npm/pip/cargo workspace parity), not on the consumer's project root. Previously a transitive '../sibling' declared inside packages/specialized/apm.yml resolved against the consumer's root, making mono-repos with sibling helper packages non-portable. Changes: - APMPackage.source_path tracks the on-disk dir each package was loaded from. Cache key is (apm_yml_path, source_path) so two loads with different anchors don't collide. - Resolver threads parent_pkg through the download_callback Protocol; legacy callbacks without the kwarg keep working via a signature introspection fallback (False on TypeError/ValueError). - Resolve phase persists a dep_key -> base_dir map to ctx so the integrate phase can copy transitive locals with the right anchor. - _copy_local_package now requires project_root (no silent skip) and enforces ensure_path_within with a red error + actionable hint on PathTraversalError. - Resolver dual-rejects local_path declared by a remote parent (relative AND absolute) at ERROR severity. - Download failures surface via _rich_warning with exc_info on a separate _logger.debug call (no more bare except: pass). - SECURITY comments on symlinks=True (preserved intentionally) and the TOCTOU race window. Tests: cache-key distinctness, source_path threading on from_apm_yml, _is_remote_parent _local/ exclusion, signature fallback, containment boundary on _copy_local_package. Closes #857. Supersedes #940. Co-authored-by: Jahanzaib Tayyab <JahanzaibTayyab@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
7 tasks
Contributor
There was a problem hiding this comment.
Pull request overview
This PR fixes how apm install resolves transitive local_path dependencies by anchoring relative paths to the declaring package's directory (instead of the consumer project root), and adds a security rule intended to prevent remote-cloned packages from declaring local_path dependencies.
Changes:
- Add
APMPackage.source_pathand include it in theapm.ymlparse cache key to avoid cross-anchor cache collisions. - Thread parent-package context through dependency resolution and use it to compute per-dependency base dirs (
ctx.dep_base_dirs) for correct transitive local anchoring. - Add docs/tests + update local package copy plumbing to take an explicit
base_dirfor relative-path resolution.
Show a summary per file
| File | Description |
|---|---|
src/apm_cli/models/apm_package.py |
Adds source_path and updates from_apm_yml cache keying to include it. |
src/apm_cli/deps/apm_resolver.py |
Threads parent_pkg through callbacks; computes source_path; attempts to reject remote-parent local_path. |
src/apm_cli/install/phases/resolve.py |
Updates download callback signature and builds ctx.dep_base_dirs from the dependency tree. |
src/apm_cli/install/phases/local_content.py |
Changes _copy_local_package to resolve relative paths against base_dir (declaring package anchor). |
src/apm_cli/install/sources.py |
Uses ctx.dep_base_dirs when copying locals and stamps source_path on loaded packages. |
tests/unit/test_local_deps.py |
Updates existing local-copy tests and adds regression tests around anchoring/caching. |
tests/unit/test_install_command.py |
Updates callback signature in a unit test to accept parent_pkg. |
tests/test_apm_resolver.py |
Adds unit coverage for _is_remote_parent and callback signature fallback. |
tests/integration/test_transitive_chain_e2e.py |
Adds an asymmetric-layout e2e regression test validating the new anchor rule. |
packages/apm-guide/.apm/skills/apm-usage/dependencies.md |
Documents the new anchor behavior (but currently claims containment). |
docs/src/content/docs/guides/dependencies.md |
Documents the new anchor behavior (but currently claims containment). |
CHANGELOG.md |
Adds an Unreleased entry describing the fix and security tightening. |
Copilot's findings
- Files reviewed: 12/12 changed files
- Comments generated: 6
C1 (logger=None AttributeError): add NullCommandLogger fallback at the top of _copy_local_package; failed first attempt to default it pipeline- wide because NullCommandLogger lacks InstallLogger-specific methods like policy_discovery_miss used by the policy_gate phase. C2 (rejected remote-parent local_path still installed): track rejected dep keys on ApmDependencyResolver._rejected_remote_local_keys, then fold them into ctx.callback_failures in resolve phase so integrate.py's existing skip gate honors the rejection. C3 (dep_base_dirs key collision): detect divergent-anchor writes in the build loop, _logger.warning loudly, keep first-write semantics. Comment documents that collision is latent today (BFS dedupes by unique key) but the guard is defensive against future BFS changes. C4/C5 (false containment claim in docs): drop the 'must stay inside consuming project root' wording from both packages/apm-guide and docs/src/content/docs; clarify sibling layouts work and the security boundary is upstream remote-parent rejection. C6 (CHANGELOG entry too long, wrong PR number): condense the 5-clause Fixed entry to a single line ending '(#1111, closes #857) Thanks @JahanzaibTayyab.' Tests: TestRemoteParentLocalPathFailClosed (2 tests) covers C2; test_copy_local_package_logger_none_does_not_raise covers C1. Verified: ruff check + format silent; 7241 unit tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TL;DR
Re-implementation of #940 (closes #857).
apm installnow anchors a package'slocal_pathreferences on that package's own directory — npm/pip/cargo parity for transitive locals — and rejects any remote-cloned package that declares alocal_pathdep. Supersedes #940; original investigation, fix architecture, and tests preserved viaCo-authored-bytrailer to @JahanzaibTayyab. All seven REQUIRED panel findings from #940 (review) plus three follow-up panel nits (python-architect, supply-chain, test-coverage) addressed in a single commit.Problem (WHY)
../siblingdeclared insidepackages/specialized/apm.ymlwas anchored on the consumer's project root, not onpackages/specialized/. The sameapm.ymlworked or broke depending on where the consumer placed it in their tree — making shared workspace layouts (mono-repos, side-by-side helper packages) impossible to express portably.APMPackage._cachewas keyed onapm_yml_pathalone, so two checkouts of the sameapm.ymlreached from different anchors collided on the first one cached.local_pathdependency, opening a prompt-injection / pseudo-supply-chain surface (a downloaded package can ask you to read arbitrary local files into your install).Approach (WHAT)
source_path(npm/pip/cargo parity)ctx.dep_base_dirs: dict[dep_key, Path]populated by resolve, consumed by integrate(apm_yml_path, source_path)ensure_path_within(project_root)(caught by CI: would have brokenapm install ../pkg-aworkflows)Implementation (HOW)
models/apm_package.py(Path, Path | None); newsource_pathfield;from_apm_yml(apm_yml_path, source_path=None).install/phases/resolve.pydownload_callbackgainsparent_pkg=None; populatesctx.dep_base_dirsby walkingdependency_tree.nodes; narrow(AttributeError, KeyError)fallback to empty map (fail-safe to legacy anchoring). Rootfrom_apm_ymlthreadsproject_rootassource_path.install/sources.pyLocalDependencySource.acquire()readsctx.dep_base_dirsforbase_dir(defensivegetattrfor legacy ctx)._materialize_localreusesbase_dir(NOTctx.project_root) when stampingsource_path— fixes architect-flagged latent cache-key bug.install/phases/local_content.py_copy_local_package(dep_ref, install_path, base_dir, *, project_root, logger).base_diris the anchor,project_rootis retained on signature for symmetry/future strict-mode hook. Requiredlogger(was silent fallback masking transitive logger-threading bugs).deps/apm_resolver.py_signature_accepts_parent_pkg(False fallback for callbacks that don't accept the kwarg yet);_is_remote_parentheuristic (excludes_local/prefix); dual-reject ABSOLUTE+RELATIVE local from remote-parent at ERROR; consistent error messages recommending "publish as standalone package";_logger.debugreplaces bareexcept: pass.CHANGELOG.md,docs/.../dependencies.md,packages/apm-guide/.apm/skills/apm-usage/dependencies.mdDiagrams
Sequence (execution flow). How
dep_base_dirsbridges resolve → integrate so the integrate phase knows which anchor to use per local dep. New behaviour is highlighted in soft-yellow blocks.sequenceDiagram participant U as User participant CLI as apm install participant R as ResolvePhase participant T as DependencyTree participant Ctx as InstallContext participant I as IntegratePhase participant LDS as LocalDependencySource participant CP as _copy_local_package participant APk as APMPackage U->>CLI: apm install CLI->>R: run(deps_to_install) R->>T: build & walk rect rgb(255, 247, 200) Note over T,Ctx: NEW: anchor map populated T-->>R: nodes (parent->child) R->>R: for node: anchor = parent.source_path or project_root R->>Ctx: ctx.dep_base_dirs[dep_key] = anchor end CLI->>I: run(deps_to_install) loop per local dep I->>LDS: acquire(dep_ref, ctx) rect rgb(255, 247, 200) Note over LDS: NEW: anchor lookup LDS->>Ctx: dep_base_dirs.get(dep_key) or project_root Ctx-->>LDS: base_dir end LDS->>CP: (dep_ref, install_path, base_dir, project_root) CP-->>LDS: install_path rect rgb(255, 247, 200) Note over LDS,APk: NEW: source_path stamped on package LDS->>APk: from_apm_yml(yml, source_path=base_dir/local) APk-->>LDS: APMPackage(source_path=...) end endClass (data model). Modules touched by this PR and the data they exchange. Notes call out the three correctness invariants the reviewers should verify hold post-merge.
classDiagram class APMPackage { +str name +str version +Path package_path +Path source_path +from_apm_yml(yml, source_path) APMPackage } class APMPackageCache { -dict~tuple~ _cache +get(yml_path, source_path) APMPackage } class DependencyReference { +str source +str local_path +bool is_local +get_unique_key() str } class InstallContext { +Path project_root +dict dep_base_dirs } class DownloadCallback { <<Protocol>> +__call__(dep_ref, target, parent_pkg) bool } class APMDependencyResolver { -_signature_accepts_parent_pkg(cb) bool -_is_remote_parent(parent_pkg) bool -_try_load_dependency_package(dep_ref, parent_pkg) APMPackage } class LocalDependencySource { +acquire(dep_ref, ctx) ResolvedDep -_materialize_local(...) APMPackage } class CopyLocalPackage { +_copy_local_package(dep_ref, install_path, base_dir, project_root, logger) Path } APMPackage ..> APMPackageCache : keyed by tuple APMDependencyResolver ..> DownloadCallback : invokes APMDependencyResolver ..> APMPackage : threads source_path LocalDependencySource ..> InstallContext : reads dep_base_dirs LocalDependencySource ..> CopyLocalPackage : delegates LocalDependencySource ..> APMPackage : stamps source_path CopyLocalPackage ..> InstallContext : reads project_root note for APMPackage "Cache key tuple (yml_path, source_path) prevents collision across anchors" note for InstallContext "dep_base_dirs is the cross-phase bridge from resolve to integrate" note for APMDependencyResolver "Dual-rejects local_path declared by remote parent (ERROR, fail-closed)"Trade-offs
dep_base_dirsvs typed field onInstallContext. Usedgetattr(ctx, "dep_base_dirs", {})for forward-compat with any legacy callers constructingInstallContextdirectly. If more phase-bridged maps accumulate, promote to a typed dataclass field (architect observation apm install <my-apm-package-repo> #5 — accepted as follow-up)._is_remote_parentheuristic is coupled to the_local/prefix convention. Defense-in-depth only — the security boundary is the upstream dual-reject; misclassification still fails closed because remote-parent local_paths are blocked at resolve. Documented in the SECURITY note on the helper.ensure_path_within(project_root)in_copy_local_package. First v2 draft enforced it; existing 2 sibling-layout e2e tests intest_transitive_chain_e2e.pyfailed against it (legitimateapm install ../pkg-awas blocked). The actual untrusted-source threat (remote-parent local_path) is already blocked upstream inapm_resolver._try_load_dependency_package, so the redundant check was overreach. Signature retained for a future opt-in strict mode hook._signature_accepts_parent_pkgin resolver. Detects callbacks that don't yet accept the newparent_pkg=Nonekwarg and falls back. Costs one introspection call per dep; alternative was a hard breaking change to every callback site, which would have widened the blast radius.Benefits
local_pathdeps — the same workspace layout works regardless of consumer location.apm.ymlfrom different anchors no longer collide.local_path.apm install ../pkg-acontinues to work; no migration needed for consumers.loggerargument removes a class of silent-progress-output regressions in transitive call paths.Validation
Note
All gates run on commit
d57cc291in the worktree (not in CI yet — CI will run on push).uv run --extra dev ruff check src/ tests/— silentuv run --extra dev ruff format --check src/ tests/— silentuv run --extra dev pytest tests/unit tests/test_console.py tests/test_apm_resolver.py— 7239 passed, 1 warning, 30 subtests passed in 69.47spytest tests/integration/test_transitive_chain_e2e.py— 3/3 pass, including the newtest_asymmetric_layout_anchors_on_declaring_pkg(asymmetric layout that only passes with the fix; the existing symmetric-sibling test is layout-degenerate and would pass either way)/tmp/apm940-repro/— both./packages/specializedand transitive../baseinstall correctly, integrating into.agents/skills/.Smoke repro output
Scenario evidence
../siblingresolves against the declaring package, not the consumer (npm/pip/cargo parity — deterministic install)tests/integration/test_transitive_chain_e2e.py::test_asymmetric_layout_anchors_on_declaring_pkgtests/unit/test_local_deps.py::TestDepBaseDirsCrossPhase::test_dep_base_dirs_anchors_transitive_on_parent_source_pathAPMPackage._cachedistinguishes same-apm.ymlloaded from different anchors (cache correctness)tests/unit/test_local_deps.py::TestSourcePathField(3 cases)local_pathis rejected at resolve, both relative and absolute (supply-chain boundary)tests/test_apm_resolver.py::TestIsRemoteParentHeuristic(3 cases)parent_pkg=keep working (backward compat)tests/test_apm_resolver.py::TestSignatureFallback(3 cases)project_root(../pkg-a) still installs (legitimate workflow preserved)tests/unit/test_local_deps.py::TestCopyLocalPackageContainmentBoundary::test_allows_sibling_outside_project_root+ e2e symmetric chainLocal panel review
sources.py:190ctx.project_root→base_dirforsource_pathstamping); narrowedexcept Exception→(AttributeError, KeyError); error-message symmetry.How to test
gh pr checkout 1111/tmp/foo/consumer/, declaring./packages/specialized;specialized/apm.ymldeclaring../base;base/as a sibling ofspecialized/underconsumer/packages/. Runapm installfromconsumer/— both packages should appear underapm_modules/_local/.consumer/apm.ymldeclaring../pkg-afrom outside the consumer dir; should still install (legitimate workflow preserved).apm.ymlfrom a remote-cloned package that declareslocal_path: ../something— expect[x]ERROR with hint about publishing.uv run --extra dev pytest tests/integration/test_transitive_chain_e2e.py -v— all 3 e2e tests pass.Closes #857. Supersedes #940.
Co-authored-by: Jahanzaib Tayyab noreply@github.com
Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com