Skip to content

fix: resolve transitive local paths against declaring package (#857)#940

Closed
JahanzaibTayyab wants to merge 5 commits intomicrosoft:mainfrom
JahanzaibTayyab:fix/857-transitive-local-path-resolution
Closed

fix: resolve transitive local paths against declaring package (#857)#940
JahanzaibTayyab wants to merge 5 commits intomicrosoft:mainfrom
JahanzaibTayyab:fix/857-transitive-local-path-resolution

Conversation

@JahanzaibTayyab
Copy link
Copy Markdown
Contributor

Description

Fixes #857.

Relative local_path dependencies declared inside a non-root package's apm.yml were resolved against the root consumer's project directory rather than the package's own source directory. This made it impossible to compose two local sibling packages where one declares the other as a dependency.

After this PR, relative paths in any apm.yml resolve against the directory containing that apm.yml, matching the semantic every other package manager (npm, pip, cargo, go modules, Bicep) uses.

Type of change

  • Bug fix
  • New feature
  • Documentation
  • Maintenance / refactor

Repro

A monorepo with two local packages:

my-project/
  apm.yml                                  # consumer
  packages/
    editorial-pipeline/apm.yml             # base
    handbook-agents/apm.yml                # depends on the base via ../editorial-pipeline

packages/handbook-agents/apm.yml:

dependencies:
  apm:
    - ../editorial-pipeline

Before this PR: apm install fails because APM resolves ../editorial-pipeline against the root consumer (my-project/), looking for my-project/../editorial-pipeline.

After this PR: APM resolves it against the directory holding the apm.yml that declared it (packages/handbook-agents/), so it finds the sibling package.

Implementation

The fix follows the design proposed in the issue:

  1. APMPackage gains source_path: Optional[Path] = None -- absolute on-disk directory holding this package's apm.yml. For the root project and for remote deps this matches the install path; for local deps it is the original source directory (not the apm_modules/_local/ copy).

  2. DownloadCallback Protocol gains parent_pkg: Optional[APMPackage] = None -- the package that declared the dependency.

  3. _try_load_dependency_package accepts parent_pkg and computes the loaded package's source_path via a small new helper _compute_dep_source_path. The caller in build_dependency_tree already has the parent node, so it just forwards parent_node.package.

  4. download_callback (in install/phases/resolve.py) computes base_dir = parent_pkg.source_path or project_root and passes that to _copy_local_package.

  5. _copy_local_package's third parameter renamed project_root -> base_dir to reflect the new semantic. Existing positional callers are unaffected.

The root project's source_path is set to project_root immediately after APMPackage.from_apm_yml() so direct deps work identically to today.

Behavior matrix

Scenario Before After
Root: ./packages/foo resolves vs project_root resolves vs project_root
Local-in-local: ../sibling broken resolves vs the parent package's source dir ✅
Remote-in-remote: ./internal-helper meaningless resolves vs the clone dir ✅
Absolute paths works works (handled by the absolute branch already)

Areas needing careful review

  1. Backwards compatibility on DownloadCallback. The Protocol now has a fourth keyword-arg parent_pkg. Any existing user-defined callback that doesn't accept it (and lacks **kwargs) will fail with a TypeError when called from _try_load_dependency_package. The resolver currently catches that as a generic download miss, so the failure is silent rather than loud. Two options if maintainers prefer different ergonomics:

    • Use inspect.signature in _try_load_dependency_package to omit parent_pkg for legacy callbacks (backwards-compatible, slightly hidden complexity).
    • Keep as-is and call this out in CHANGELOG.md as a minor breaking change for callback authors.
      I went with the second since the issue explicitly proposed the Protocol change and the only in-tree caller is the one we control. Happy to switch.
  2. Lockfile entries for transitive local deps. lockfile.py serialises local_path as a string. For transitive local deps, the string's anchor changes (relative-to-source vs. relative-to-root). A regenerate handles it; direct local deps are unaffected. I did not modify the lockfile schema, in line with the "Out of scope" section of the issue.

  3. Install-path collisions (_local/<dirname> collision when two parents declare local deps with the same final folder name) is pre-existing and unchanged by this PR. The issue calls this out as orthogonal.

Tests

7 new tests in tests/test_apm_resolver.py:

  • TestSourcePathField (2): default-None, settable.
  • TestComputeDepSourcePath (5): remote dep returns install_path; local absolute resolved; local relative uses parent's source_path (the bug-fix path); local relative falls back to project_root when no parent; backwards-compat fallback when parent has no source_path.
  • TestResolverSetsRootSourcePath (1): root source_path populated after resolve.
  • TestDownloadCallbackReceivesParent (1): parent_pkg arg flows through to the callback.

One existing test (tests/unit/test_install_command.py::TestTransitiveDepParentChain::test_download_callback_includes_chain_in_error) was updated -- its mock callback signature gained parent_pkg=None to match the extended Protocol.

Testing

  • Tested locally
  • All existing tests pass (uv run pytest tests/unit tests/test_console.py: 5,466 passed, matches main minus the pre-existing f-string-in-test_audit_report.py collection error which exists on main)
  • Added tests for new functionality

Prepared with assistance from Claude (Anthropic) under human review.

…oft#857)

Relative ``local_path`` dependencies declared inside a non-root
package's apm.yml were resolved against the root consumer's project
directory rather than the package's own source directory. This made it
impossible to compose two local sibling packages where one declares
the other as a dependency.

Introduce ``APMPackage.source_path`` to track the on-disk directory
holding each package's apm.yml, and thread the declaring package
through the download callback so ``_copy_local_package`` can anchor
relative paths on the right base directory. Aligns with the semantic
every other package manager uses for relative paths in manifests.

Prepared with assistance from Claude (Anthropic) under human review.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes transitive local dependency resolution so relative local_path entries in a package's apm.yml resolve relative to the declaring package directory (not the root consumer), enabling sibling local package composition.

Changes:

  • Add APMPackage.source_path to track the on-disk directory that owns a package's apm.yml.
  • Extend the resolver download callback to receive parent_pkg and compute the correct base directory for resolving transitive local paths.
  • Add unit tests covering source_path and source-path computation behavior.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
tests/unit/test_install_command.py Updates a resolver callback mock signature to accept the new parent_pkg argument.
tests/test_apm_resolver.py Adds tests for APMPackage.source_path, _compute_dep_source_path, and callback argument threading.
src/apm_cli/models/apm_package.py Introduces source_path field on APMPackage to anchor relative local path resolution.
src/apm_cli/install/phases/resolve.py Uses parent_pkg.source_path (when available) to resolve transitive local paths against the declaring package.
src/apm_cli/install/phases/local_content.py Renames _copy_local_package parameter project_root -> base_dir and updates resolution accordingly.
src/apm_cli/deps/apm_resolver.py Threads parent_pkg through resolution, sets source_path, and adds _compute_dep_source_path.

Comment thread src/apm_cli/deps/apm_resolver.py Outdated
Comment thread src/apm_cli/install/phases/resolve.py
Comment thread tests/test_apm_resolver.py
Comment thread src/apm_cli/deps/apm_resolver.py Outdated
…tion (microsoft#940)

- Detect whether download_callback accepts parent_pkg via signature
  inspection. Legacy 3-arg callbacks no longer raise a silent TypeError
  that would mask the dependency; the resolver gracefully falls back to
  the pre-microsoft#857 call shape and pre-microsoft#857 resolution semantic.
- Disambiguate the per-resolution download dedup key for local deps:
  the literal local_path string can refer to different on-disk packages
  depending on the declaring parent's source_path, so include the
  resolved absolute source path in the cache key (`local::<resolved>`).
  Remote deps are unchanged.
- Update docs/guides/dependencies.md to reflect the new resolution
  semantic ("relative to this apm.yml's directory") and add a bullet
  explaining how relative paths in nested packages compose.
- Tests:
  - Transitive case where parent_pkg is non-None and source_path is set
    (mid-pkg declares user/leaf-dep; assert callback receives mid-pkg).
  - Legacy callback compatibility: 3-arg callable still invoked.
  - Signature detection: modern, legacy, and **kwargs callables.
  - Local dedup key separation: same local_path under different parents
    produces distinct keys.
@JahanzaibTayyab
Copy link
Copy Markdown
Contributor Author

@microsoft-github-policy-service agree

@JahanzaibTayyab
Copy link
Copy Markdown
Contributor Author

Thanks for the careful Copilot review. Pushed c568b9d addressing all four comments:

1. Silent TypeError on legacy callbacks (apm_resolver.py:424) — added _signature_accepts_parent_pkg() that inspects download_callback.__signature__ once at construction. The resolver now branches: modern callbacks get the 4-arg call (with parent_pkg), legacy 3-arg callbacks get the pre-#857 call shape. No silent skip, no breaking change for existing callback authors. Detection treats **kwargs as accepting parent_pkg and falls back to the modern path if signature introspection fails (e.g. C extensions).

2. Docs (resolve.py:158docs/src/content/docs/guides/dependencies.md) — both occurrences of # relative to project root now say # relative to this apm.yml's directory, and the "How it works" bullet list gains an explicit note explaining that nested-package declarations resolve against their own directory (with the npm/pip/cargo analogy and the absolute-path escape hatch).

3. Transitive callback test (test_apm_resolver.py:608) — added test_callback_receives_parent_for_transitive_dep which sets up a real 2-level structure: root → local ./packages/mid → remote user/leaf-dep. It asserts the leaf invocation carries the mid-pkg APMPackage as parent_pkg and that parent_pkg.source_path is populated, pinning the contract that #857 actually depends on.

4. Local dedup key collision (apm_resolver.py:411) — Copilot's analysis is correct: the literal local_path (e.g. ../common) can refer to different on-disk dirs once #857 anchors it on the parent. I left DependencyReference.get_unique_key() alone (it's used in many places, including the lockfile, and changing it broadly is out of scope per the issue's "Lockfile schema unchanged" note), and instead introduced _download_dedup_key() scoped to the per-resolution download cache. For remote deps it returns dep_ref.get_unique_key() unchanged; for local deps it returns local::<resolved-abs-path>. New tests pin both branches and the separation across two synthetic parents pointing at different siblings.

Also added a TestLegacyDownloadCallbackCompatibility class with three signature-detection tests (modern / legacy / **kwargs) and an end-to-end test that the resolver successfully invokes a 3-arg callback.

Full suite: uv run pytest tests/unit tests/test_console.py5,466 passed (matches main).

Also signed the CLA above.

The pre-existing concerns called out in the PR description (lockfile entries for transitive local deps, _local/<dirname> install-path collisions) remain as the issue notes — happy to spin those off into separate PRs if maintainers want them tackled here.

@danielmeppiel danielmeppiel added the panel-review Trigger the apm-review-panel gh-aw workflow label Apr 28, 2026
@github-actions
Copy link
Copy Markdown

APM Review Panel Verdict

Disposition: REQUEST_CHANGES (two pre-merge doc/admin actions; code and tests are solid)


Per-persona findings

Python Architect:

This is a routine PR -- bug fix in the dependency resolution engine with backward-compat introspection and a new optional field on a dataclass. Not a major architectural change; one class diagram + one flow diagram.

1. OO / class diagram

classDiagram
    direction TB
    class APMDependencyResolver {
        <<CoreEngine>>
        +_project_root: Path
        +_download_callback: DownloadCallback
        +_callback_accepts_parent_pkg: bool
        +resolve_dependencies(project_root) DependencyGraph
        +_try_load_dependency_package(dep_ref, parent_chain, parent_pkg) APMPackage
        +_compute_dep_source_path(dep_ref, install_path, parent_pkg) Path
        +_download_dedup_key(dep_ref, parent_pkg) str
        +_signature_accepts_parent_pkg(callback) bool
    }
    class APMPackage {
        <<ValueObject>>
        +name: str
        +version: str
        +package_path: Optional[Path]
        +source_path: Optional[Path]
        +dependencies: Dict
        +from_apm_yml(path) APMPackage
    }
    class DownloadCallback {
        <<Protocol>>
        +__call__(dep_ref, apm_modules_dir, parent_chain, parent_pkg) Optional[Path]
    }
    class DependencyReference {
        +repo_url: str
        +is_local: bool
        +local_path: str
        +get_install_path(apm_modules_dir) Path
        +get_unique_key() str
    }
    class DependencyGraph {
        +root_package: APMPackage
        +dependencies: List
    }
    class DependencyNode {
        +package: APMPackage
        +get_ancestor_chain() str
    }
    APMDependencyResolver *-- DownloadCallback : invokes
    APMDependencyResolver ..> APMPackage : creates/reads
    APMDependencyResolver ..> DependencyReference : reads
    APMDependencyResolver ..> DependencyGraph : produces
    APMDependencyResolver ..> DependencyNode : traverses
    DownloadCallback ..> APMPackage : receives parent_pkg
    DependencyGraph *-- APMPackage : root_package
    DependencyNode *-- APMPackage : package
    note for APMDependencyResolver "Adapter: _signature_accepts_parent_pkg\ndetects legacy callbacks at init time"
    class APMDependencyResolver:::touched
    class APMPackage:::touched
    class DownloadCallback:::touched
    classDef touched fill:#fff3b0,stroke:#d47600
Loading

2. Execution flow diagram

flowchart TD
    A["apm install (CLI)"] --> B["resolve.run(ctx)\nsrc/apm_cli/install/phases/resolve.py"]
    B --> C["APMDependencyResolver.resolve_dependencies(project_root)\nsrc/apm_cli/deps/apm_resolver.py"]
    C --> D["[I/O] APMPackage.from_apm_yml(apm_yml_path)\nroot_package.source_path = project_root.resolve()"]
    D --> E["build_dependency_tree: BFS deque of DependencyNodes"]
    E --> F{"next dep_ref in queue?"}
    F -->|yes| G["_try_load_dependency_package(dep_ref, parent_chain,\nparent_pkg=parent_node.package)"]
    G --> H{"install_path.exists()?"}
    H -->|no| I{"_callback_accepts_parent_pkg?"}
    I -->|yes| J["download_callback(dep_ref, modules_dir,\nparent_chain, parent_pkg)\nresolve.py closure"]
    I -->|no| K["legacy: download_callback(dep_ref, modules_dir, parent_chain)\nsilent fallback to project_root anchor"]
    J --> L{"parent_pkg.source_path set?"}
    L -->|yes| M["[FS] _copy_local_package(dep_ref, install_path,\nbase_dir=parent_pkg.source_path)\nlocal_content.py"]
    L -->|no| N["[FS] _copy_local_package(dep_ref, install_path,\nbase_dir=project_root)\nlocal_content.py"]
    H -->|yes| O["_compute_dep_source_path(dep_ref, install_path, parent_pkg)"]
    M --> O
    N --> O
    O --> P{"dep_ref.is_local?"}
    P -->|absolute| Q["return local.resolve()"]
    P -->|relative + parent.source_path| R["return (parent.source_path / local).resolve()"]
    P -->|relative + no parent| S["return (project_root / local).resolve()"]
    P -->|remote dep| T["return install_path.resolve()"]
    Q & R & S & T --> U["package.source_path = resolved_source_path\nrecurse into package's own deps"]
    U --> F
    F -->|empty| V["[LOCK] produce DependencyGraph"]
```

#### 3. Design patterns

**Design patterns**
- Used in this PR: Adapter -- `_signature_accepts_parent_pkg` (static method on `APMDependencyResolver`) detects legacy 3-arg callback signatures at construction time so the modern 4-arg path is preferred and the legacy path degrades gracefully without a TypeError surface. Dataclass-as-value-object -- `APMPackage.source_path` carries the resolved anchor immutably through the BFS traversal.
- Pragmatic suggestion: The base_dir computation `(parent_pkg.source_path if parent_pkg is not None and parent_pkg.source_path is not None else self._project_root)` is duplicated verbatim in `_download_dedup_key` and `_compute_dep_source_path`. Extract to a one-liner helper method `_effective_base_dir(parent_pkg)` to eliminate the two-site duplication. The refactor is four lines; the benefit is a single place to change if the fallback logic evolves.

**Assessment**: Architecture is clean and appropriately scoped. The BFS parent-node forwarding (`parent_node.package if parent_node else None`) is the idiomatic way to thread context through the tree walk. The `inspect.signature` detection is a well-understood backward-compat pattern. The `source_path` field is the right place on `APMPackage`. One minor concern: `_signature_accepts_parent_pkg` returns `True` for unresolvable signatures (C extensions), which means the resolver will attempt the 4-arg call and fall into the `except Exception: pass` in `_try_load_dependency_package`, silently dropping the dependency. This is pre-existing behavior for the exception-swallow; the True default is the right bias. No blocker.

---

**CLI Logging Expert**:

The PR adds no new user-facing log output, STATUS_SYMBOLS, CommandLogger methods, or diagnostic messages. The `_copy_local_package` function receives its `logger` parameter and passes it through unchanged -- correct.

One pre-existing note (not introduced by this PR): the `except Exception: pass` in `_try_load_dependency_package` swallows download failures silently. A verbose-mode log of the caught exception at `[!]` level would help users debug silent dep-miss failures. This is pre-existing technical debt; flagging for follow-up only. No output-path misuse, no direct `_rich_*` calls outside console code. CLEAN.

---

**DevX UX Expert**:

The fix aligns APM with npm, pip, cargo, and go module semantics for relative paths -- exactly the right call. A monorepo user who hit this bug will now find APM behaves as expected without reading docs.

**Positive**: The doc comment update (`"relative to this apm.yml's directory"`) and the bolded note in `dependencies.md` are clear and accurate. The `inspect.signature` mitigation means existing callback authors are not silently broken at runtime.

**Required (Rule 4)**: `packages/apm-guide/.apm/skills/apm-usage/dependencies.md` is NOT updated. The local-path section of that file currently describes paths as resolving relative to the project root. The behavioral change (anchor on declaring `apm.yml`) must be reflected there so agents that consume the skill resource give users correct guidance. The update is one sentence.

**Required (repo convention)**: `CHANGELOG.md` has no entry for this fix. Per repo changelog convention, every merged PR that changes behavior must have a "Fixed" entry. For a `DownloadCallback` Protocol signature change (even with compat shim), a brief note is owed to callback authors. Suggested entry:

```
### Fixed
- Relative `local_path` dependencies declared in nested packages now resolve
  against the declaring package's directory instead of the project root,
  matching npm/pip/cargo semantics; enables sibling-package composition in
  monorepos (#857) -- by @JahanzaibTayyab (#940)
Loading

Supply Chain Security Expert:

Path traversal surface: _copy_local_package resolves local = (base_dir / dep_ref.local_path).resolve() but does NOT call path_security.validate_path_segments on dep_ref.local_path before joining, nor ensure_path_within(local, project_root) after resolution. This is pre-existing -- the PR inherits this gap, it does not introduce it. The install_path target is guarded via safe_rmtree and the apm_modules_dir containment comment at line 128, but the source path (local) is not containment-checked.

New exposure introduced by this PR: base_dir can now be parent_pkg.source_path, which for a remote package is apm_modules/<owner>/<repo>/. A malicious remote package could declare ../../../.ssh as a local dep, resolving base_dir/../../../.ssh = project_root/.ssh or higher. This widens (slightly) the pre-existing gap because base_dir is no longer always the user-controlled project root. Flag as a follow-up security hardening issue (not a blocker for this PR, since: 1) remote packages declaring local deps is semantically unusual and would fail validation, 2) the gap predates this PR).

Dedup key: _download_dedup_key using resolved absolute paths for local deps is correct -- prevents the same on-disk package from being downloaded twice regardless of how it is referenced textually. No security concern.

Backward-compat shim: inspect.signature detection is safe; no credential or token surface is touched.

Fails-closed posture: No change -- local path resolution fails closed (missing dir, missing apm.yml, or missing SKILL.md all return None or produce an error log).


Auth Expert: Not activated -- PR modifies only local dependency path resolution in apm_resolver.py, local_content.py, resolve.py, apm_package.py, and tests; no authentication, token management, credential resolution, or host classification code is touched.


OSS Growth Hacker:

Story angle: "APM now resolves local deps the same way npm, pip, and cargo do." This is a high-value one-liner for the release notes -- it directly addresses a pain point that would cause monorepo users to churn. The fix enables composable multi-agent pipelines (e.g. handbook-agents depending on editorial-pipeline as sibling packages), which reinforces the "AI-native development" positioning.

External contributor (@JahanzaibTayyab) deserves visible credit in the CHANGELOG. External contributor PRs are the most powerful OSS signal -- highlighting them in release notes compounds contributor acquisition.

Side-channel note to CEO: This fix is worth a dedicated mention in the next release post. The before/after monorepo repro in the PR description is already release-post-ready. Consider it as evidence for a "monorepo-native" positioning beat.


CEO arbitration

The specialist panel is aligned. The code change is correct, clean, well-tested (9 new test cases covering the bug path, backward compat, and edge cases), and matches the semantic contract of every major package manager. The inspect.signature backward-compat shim is the right pragmatic call for the protocol change -- the PR author correctly identified and implemented the preferred option.

Two administrative gaps block merge: a missing CHANGELOG.md entry (repo convention and external-contributor credit) and a missing update to packages/apm-guide/.apm/skills/apm-usage/dependencies.md (Rule 4, behavioral change to dependency format). Both are one-to-three line changes; neither requires code rework. The supply chain path-traversal gap is pre-existing and should be tracked as a follow-up issue rather than blocking this PR, since the PR narrows not widens the practical attack surface (relative local deps from remote packages were already unguarded and would fail package validation before copy). The growth hacker's note about release narrative is well-taken; I will flag this PR for the next release summary.


Required actions before merge

  1. Add a CHANGELOG.md entry under ## [Unreleased] > ### Fixed describing the transitive local-path anchor fix with credit to @JahanzaibTayyab. See the suggested entry in the DevX section above.
  2. Update packages/apm-guide/.apm/skills/apm-usage/dependencies.md local-path section to note that relative paths anchor on the declaring apm.yml's directory, not the project root. One sentence aligned with the dependencies.md doc update already in this PR.

Optional follow-ups

  • Extract _effective_base_dir(parent_pkg) helper method on APMDependencyResolver to eliminate the duplicated parent_pkg.source_path or self._project_root logic in _download_dedup_key and _compute_dep_source_path.
  • Open a follow-up security hardening issue to add path_security.validate_path_segments(dep_ref.local_path) and ensure_path_within(local, project_root) guards in _copy_local_package, covering both the pre-existing gap and the slightly widened surface from base_dir variability.
  • Add a verbose-mode log ([!] level) in the except Exception: pass block of _try_load_dependency_package to surface silent download failures when --verbose is active.
  • Consider explicitly rejecting local_path declarations found inside remotely-fetched packages (not user-controlled local deps), as they are semantically surprising and constitute the most realistic path-traversal vector.

Generated by PR Review Panel for issue #940 · ● 757.3K ·

Copy link
Copy Markdown
Collaborator

@danielmeppiel danielmeppiel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Required actions before merge
Add a CHANGELOG.md entry under ## [Unreleased] > ### Fixed describing the transitive local-path anchor fix with credit to @JahanzaibTayyab. See the suggested entry in the DevX section above.
Update packages/apm-guide/.apm/skills/apm-usage/dependencies.md local-path section to note that relative paths anchor on the declaring apm.yml's directory, not the project root. One sentence aligned with the dependencies.md doc update already in this PR.
Extract _effective_base_dir(parent_pkg) helper method on APMDependencyResolver to eliminate the duplicated parent_pkg.source_path or self._project_root logic in _download_dedup_key and _compute_dep_source_path.
Open a follow-up security hardening issue to add path_security.validate_path_segments(dep_ref.local_path) and ensure_path_within(local, project_root) guards in _copy_local_package, covering both the pre-existing gap and the slightly widened surface from base_dir variability.
Add a verbose-mode log ([!] level) in the except Exception: pass block of _try_load_dependency_package to surface silent download failures when --verbose is active.
Consider explicitly rejecting local_path declarations found inside remotely-fetched packages (not user-controlled local deps), as they are semantically surprising and constitute the most realistic path-traversal vector.

@danielmeppiel danielmeppiel self-requested a review April 30, 2026 05:13
@danielmeppiel danielmeppiel dismissed their stale review April 30, 2026 05:13

re-review

danielmeppiel and others added 3 commits April 30, 2026 07:15
…crosoft#940)

Fold all required actions from review #4191366889 into this PR:

- Extract _effective_base_dir(parent_pkg) helper on APMDependencyResolver
  so _download_dedup_key and _compute_dep_source_path share one rule for
  parent-or-project-root anchoring.
- Reject relative local_path declared inside remotely-fetched packages
  (the most plausible path-traversal vector); direct deps from the root
  project and absolute paths are unaffected. New _is_remote_parent helper
  checks containment under apm_modules_dir.
- Surface previously silent transitive download failures via the stdlib
  logger so --verbose makes the skip diagnosable; partial resolution
  still works.
- Harden _copy_local_package: optional project_root parameter triggers
  ensure_path_within(local, project_root) so a transitive base_dir
  cannot widen the surface to paths outside the project. (We deliberately
  do not call validate_path_segments on local_path because it would
  reject the legitimate one-level-up form ../sibling that this PR
  enables; ensure_path_within is the actual security boundary.)
- CHANGELOG: add Fixed entry under [Unreleased] crediting
  @JahanzaibTayyab (microsoft#857).
- packages/apm-guide/.apm/skills/apm-usage/dependencies.md: document
  the declaring-apm.yml anchoring rule and the rejection of local_path
  in remote packages.
- Tests: cover _effective_base_dir, the remote-parent rejection guard,
  the verbose-mode warning on download failure, and the
  ensure_path_within containment guard in _copy_local_package.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@danielmeppiel danielmeppiel added panel-review Trigger the apm-review-panel gh-aw workflow and removed panel-review Trigger the apm-review-panel gh-aw workflow labels Apr 30, 2026
@github-actions
Copy link
Copy Markdown

APM Review Panel Verdict: REJECT

The core path-anchoring fix is architecturally correct and long overdue, but 7 required findings block merge: a mutable-cache hazard reintroduces the fixed bug for multi-run processes, the path-containment guard is opt-in rather than mandatory, security rejections use the wrong severity level, download failures are invisible at default verbosity, and migration guidance is absent from a behavior-breaking change.

Required before merge (7 items)

  • [python-architect] Mutable APMPackage cached before source_path is set; cross-run cache poisoning is possible at src/apm_cli/models/apm_package.py

    • Why: from_apm_yml stores the APMPackage instance in _apm_yml_cache before returning it. Callers then mutate package.source_path post-construction on the shared cached object. In a long-lived process or a test suite that forgets clear_apm_yml_cache(), a second resolution with a different project_root will read the source_path left by the first run, silently anchoring transitive local_path deps to the wrong directory -- the very bug this PR fixes for the single-run case.
    • Suggested fix: Either accept source_path as a parameter of from_apm_yml and include it in the cache key, or make APMPackage @dataclass(frozen=True) and build a thin mutable wrapper for the fields that legitimately change after load.
  • [cli-logging-expert] Download failure warning is gated to --verbose only, silently dropping transitive deps for users running without --verbose at src/apm_cli/deps/apm_resolver.py

    • Why: Python logging at WARNING level is only visible when a handler is wired up, and APM only does that in verbose mode. A user who gets no transitive dep installed will see nothing actionable. Silent failures that affect install outcome must surface at default verbosity via DiagnosticCollector (category: warning) so they appear in the install summary.
    • Suggested fix: Route the download-failure finding through DiagnosticCollector so it appears in the install summary at default verbosity. Reserve exc_info=True traceback to --verbose only.
  • [cli-logging-expert] Security rejection of relative local_path uses _logger.warning (yellow) instead of error (red), violating the traffic light rule at src/apm_cli/deps/apm_resolver.py

    • Why: Rejecting a dependency on security grounds is a "must act" event -- traffic light rule says Red = error. Using WARNING contradicts severity, and a handler filtered at ERROR level silently drops it. The message also has no fix hint, leaving the author no guidance.
    • Suggested fix: Change to _logger.error(...) and append a fix hint, e.g. "Use an absolute path or move the dependency declaration to your root apm.yml."
  • [devx-ux-expert] Error message "Refusing to copy local package: ... resolves outside the project root" is not actionable -- no recovery hint at src/apm_cli/install/phases/local_content.py

    • Why: Every error must name what failed, why, and one concrete next action. Per the DevX mental model, "Failure mode is the product." No next step leaves users searching the internet before they figure out the fix.
    • Suggested fix: Append "Use an absolute path or move the package inside the project root." to the error message.
  • [devx-ux-expert] CHANGELOG does not call out the breaking behavior change or provide migration guidance at CHANGELOG.md

    • Why: Any monorepo that had a transitive apm.yml with a relative local_path that resolved from root under the old behavior will silently break on upgrade. The entry names the new behavior but provides zero migration guidance. A user scanning the changelog before upgrading cannot assess impact.
    • Suggested fix: Add a Migration sub-note: "If any transitive apm.yml relied on root-relative resolution, update those paths to be absolute or move them to the root apm.yml."
  • [supply-chain-security-expert] ensure_path_within guard is opt-in: project_root=None silently skips containment check at src/apm_cli/install/phases/local_content.py:116

    • Why: The security-critical ensure_path_within call is gated on if project_root is not None. Both current callers pass project_root correctly, but the None default means any future call site or legacy callback silently bypasses the only containment guard, allowing arbitrary path writes outside the project root. A security control that can be silently disabled by omission must fail-closed.
    • Suggested fix: Remove the project_root=None default; require the argument (or raise ValueError if None is passed when dep_ref.is_local). The guard should be unconditional.
  • [oss-growth-hacker] No migration path documented for users whose transitive local_path deps accidentally resolved from root and now break at CHANGELOG.md

    • Why: The behavior change is a silent breaking change for any repo that had working transitive local_path deps under the old behavior. A user hitting a broken build after upgrading has no clue what changed or how to fix it. This is a community-trust event, not a documentation nicety.
    • Suggested fix: Add a Migration callout: "If you had a transitive package whose local_path accidentally resolved from the project root, update that package's apm.yml to use a path relative to its own directory, or pin it as a direct dependency of the root."

Nits (13 items, skip if you want)

  • [python-architect] _signature_accepts_parent_pkg falls back to True for unintrospectable callables, which will TypeError at runtime -- change except (TypeError, ValueError): return True to return False at src/apm_cli/deps/apm_resolver.py
  • [python-architect] build_dependency_tree bare except-pass still silently discards apm.yml parse errors; replace pass with _logger.debug('Could not load transitive apm.yml for %s: %s', dep_ref.get_display_name(), e) at src/apm_cli/deps/apm_resolver.py
  • [python-architect] DownloadCallback is @runtime_checkable but the compatibility gate uses signature inspection, not isinstance; remove the decorator to avoid dead overhead at src/apm_cli/deps/apm_resolver.py
  • [cli-logging-expert] _rich_error fallback in local_content.py is an anti-pattern when CommandLogger is not threaded through; require callers to always pass a logger rather than falling back to _rich_error at src/apm_cli/install/phases/local_content.py
  • [cli-logging-expert] exc_info=True on the download WARNING will emit a full traceback in verbose output; move to a subsequent _logger.debug(..., exc_info=True) call at src/apm_cli/deps/apm_resolver.py
  • [devx-ux-expert] New documentation bullet is dense; a short Before/After YAML snippet would make the anchor rule immediately scannable for users who do not read prose at docs/src/content/docs/guides/dependencies.md
  • [devx-ux-expert] Rejection warning for remote packages lacks a fix hint for the remote-package author; append "(use an absolute path or publish the dependency as a standalone package)" at src/apm_cli/deps/apm_resolver.py
  • [supply-chain-security-expert] Absolute local_path from a remote package bypasses _is_remote_parent early-reject with no warning; add an analogous rejection branch for absolute paths declared inside a remote parent at src/apm_cli/deps/apm_resolver.py:441
  • [supply-chain-security-expert] shutil.copytree(symlinks=True) preserves in-package symlinks that may escape project root when dereferenced downstream; document this as intentional for user-authored packages at src/apm_cli/install/phases/local_content.py:161
  • [supply-chain-security-expert] _is_remote_parent heuristic not documented as a security boundary and lacks an adversarial unit test; add a comment and a test covering a local_path that resolves into apm_modules_dir at src/apm_cli/deps/apm_resolver.py:567
  • [oss-growth-hacker] CHANGELOG entry buries user benefit behind implementation detail; lead with "Monorepo sibling packages now resolve paths the same way npm, pip, and cargo do" at CHANGELOG.md
  • [oss-growth-hacker] No repostable one-liner exists in the docs or CHANGELOG; add a hook sentence to the dependencies guide at docs/src/content/docs/guides/dependencies.md
  • [oss-growth-hacker] Monorepo quickstart or templates not updated to demonstrate the fixed behavior; a two-package sibling example would make this tangible and drive real adoption

CEO arbitration

Five active panelists reviewed PR #940 and collectively surfaced 7 required findings. All agree the core fix -- anchoring transitive local_path resolution to the declaring apm.yml rather than the project root -- is architecturally correct and long overdue. The implementation is directionally sound; the gaps are real enough to block merge.

Three findings carry the highest strategic weight and should be addressed together. First, python-architect identifies that APMPackage is cached before source_path is set, meaning a second resolution with a different project_root can silently read a stale anchor -- the very bug this PR purports to fix, reintroduced as a cache hazard. Second, supply-chain-security-expert flags that the ensure_path_within containment guard is opt-in via a project_root=None default: any future call site silently bypasses the only path-escape control. Both of these are fail-open by default on security-critical paths, which is unacceptable; they must fail-closed. Third, both devx-ux-expert and oss-growth-hacker independently require migration guidance in CHANGELOG.md. Any monorepo that had transitive local_path deps resolving from root under the old behavior will silently break on upgrade -- that is a community-trust event, not a documentation nicety. cli-logging-expert adds two required findings of equal urgency: download failures are gated to --verbose-only visibility (silent data loss for normal users), and security rejections are logged at WARNING rather than ERROR, violating the project's own traffic-light severity convention.

The fix path is clear: make the cache key include source_path (or freeze APMPackage), make project_root a required argument in _copy_local_package, add migration prose to CHANGELOG.md, route download-failure findings through DiagnosticCollector, and promote the security-rejection log to ERROR with a fix hint. None of these require rethinking the design; they are hardening and communication work that should land in the same PR.

Dissent resolved: No panelist disagreed on severity classification. The CHANGELOG migration finding was raised independently as REQUIRED by both devx-ux-expert and oss-growth-hacker, which reinforces rather than divides. The distinction between the two is framing: devx-ux-expert argues from user actionability, oss-growth-hacker from community retention. Both are right; the fix satisfies both. No CEO override needed.

Growth/positioning note: oss-growth-hacker notes this fix delivers what every monorepo user has wanted: predictable sibling-package resolution matching npm, pip, and cargo semantics. Recommended release framing: "APM now plays by the same rules as every other package manager -- your monorepo just works." A 30-second GIF of a two-package monorepo resolving cleanly would make this repostable. The path-containment security hardening is a quiet trust signal worth a one-liner in the release post, especially for enterprise evaluators.


Per-persona findings (full)

Python Architect

classDiagram
    direction LR

    class DownloadCallback {
        <<Protocol / Strategy>>
        +__call__(dep_ref, apm_modules_dir, parent_chain, parent_pkg) Optional~Path~
    }
    note for DownloadCallback "Strategy: injectable download\nbehavior; legacy callbacks\ndetected via signature inspection"

    class APMPackage {
        <<Dataclass / ValueObject>>
        +name str
        +version str
        +source_path Optional~Path~
        +package_path Optional~Path~
    }
    note for APMPackage "source_path set POST-CONSTRUCTION\non cached instance -- cache risk"

    class _copy_local_package {
        <<Pure / IOBoundary>>
        +__call__(dep_ref, install_path, base_dir, logger, project_root) Optional~Path~
    }

    class APMDependencyResolver {
        <<Strategy Host>>
        +_download_callback Optional~DownloadCallback~
        +_callback_accepts_parent_pkg bool
        +resolve_dependencies(project_root) DependencyGraph
        +build_dependency_tree(root_apm_yml) DependencyTree
        +_try_load_dependency_package(dep_ref, parent_chain, parent_pkg) Optional~APMPackage~
        +_is_remote_parent(parent_pkg) bool
        +_effective_base_dir(parent_pkg) Optional~Path~
        +_download_dedup_key(dep_ref, parent_pkg) str
        +_compute_dep_source_path(dep_ref, install_path, parent_pkg) Path
        +_signature_accepts_parent_pkg(callback) bool
    }
    note for APMDependencyResolver "Strategy Host: download_callback\ninjected at construction;\nAdapter: _signature_accepts_parent_pkg\nbridges legacy to new Protocol"

    class DependencyReference {
        <<ValueObject>>
        +is_local bool
        +local_path Optional~str~
        +repo_url Optional~str~
        +get_install_path(modules_dir) Path
        +get_unique_key() str
    }

    class DependencyNode {
        +package APMPackage
        +dependency_ref DependencyReference
        +depth int
        +parent Optional~DependencyNode~
        +children List~DependencyNode~
        +get_ancestor_chain() str
    }

    class LocalDependencySource {
        <<Strategy / ConcreteSource>>
        +acquire() Optional~Materialization~
    }

    class DownloadCallback:::touched
    class APMPackage:::touched
    class _copy_local_package:::touched
    class APMDependencyResolver:::touched
    class LocalDependencySource:::touched

    APMDependencyResolver *-- DownloadCallback : injects Strategy
    APMDependencyResolver ..> APMPackage : produces and mutates source_path
    APMDependencyResolver ..> DependencyReference : consumes
    APMDependencyResolver o-- DependencyNode : traverses
    DependencyNode *-- APMPackage : carries
    DependencyNode *-- DependencyReference : carries
    LocalDependencySource ..> _copy_local_package : delegates FS
    LocalDependencySource ..> APMPackage : reads source_path

    classDef touched fill:#fff3b0,stroke:#d47600
Loading
flowchart TD
    A([apm install CLI]) --> B[phases/resolve.py run]
    B --> C[APMDependencyResolver.__init__\n_signature_accepts_parent_pkg called once]
    C --> D[resolve_dependencies\nproject_root captured]
    D --> E[[FS] APMPackage.from_apm_yml\nroot apm.yml loaded and cached]
    E --> F[[FS] root_package.source_path = project_root.resolve]
    F --> G[build_dependency_tree BFS loop]

    G --> H{dep at depth greater than 0?}
    H -->|yes| I[_try_load_dependency_package\ndep_ref + parent_chain + parent_pkg]
    H -->|no| G

    I --> J{dep_ref.is_local AND\nnot absolute AND\n_is_remote_parent?}
    J -->|yes -- REJECT| K[_logger.warning\nreturn None]
    J -->|no -- ALLOW| L{install_path.exists?}

    L -->|no| M{_download_callback set?}
    M -->|yes| N{_callback_accepts_parent_pkg?}
    N -->|True modern path| O[download_callback\ndep_ref modules_dir parent_chain parent_pkg]
    N -->|False legacy path| P[download_callback\ndep_ref modules_dir parent_chain]
    M -->|no| Q[return None]

    O --> R{dep_ref.is_local?}
    P --> R
    R -->|yes| S[_effective_base_dir\nparent_pkg.source_path OR project_root]
    S --> T[[FS] _copy_local_package\ndep_ref install_path base_dir project_root]
    T --> T1{ensure_path_within\nlocal inside project_root?}
    T1 -->|no -- PathTraversalError| U[log error return None]
    T1 -->|yes| V[[FS] shutil.copytree to apm_modules/_local/name]
    V --> W[_downloaded_packages key added]
    R -->|no -- remote| X[[NET] GitHubPackageDownloader.download]
    X --> W

    L -->|yes| Y[_compute_dep_source_path\noriginal src dir for local -- install_path for remote]
    W --> Y
    Y --> Z[[FS] APMPackage.from_apm_yml install_path/apm.yml]
    Z --> AA[package.source_path = resolved_source_path\nMUTATES cached APMPackage]
    AA --> AB[node.package = loaded_package]
    AB --> AC[enqueue sub-deps with parent_pkg=loaded_package]
    AC --> G
Loading

Design patterns

  • Used in this PR: Strategy -- DownloadCallback Protocol is injected into APMDependencyResolver.__init__; the resolver never knows whether the callback clones from GitHub or copies a local dir, matching the existing Strategy pattern in AuthResolver.
  • Used in this PR: Adapter -- _signature_accepts_parent_pkg bridges legacy callbacks (pre-Local relative paths in transitive deps resolve against root consumer instead of declaring package #857, no parent_pkg param) to the new Protocol via inspect.signature; callers remain uniform, the adapter gates which call path runs.
  • Used in this PR: Value Object (partial) -- APMPackage is a @dataclass carrying source_path as an anchor; it is treated as an immutable value object by consumers but is actually mutable and cached, which undermines the pattern (see required finding).
  • Pragmatic suggestion: Freeze APMPackage or pass source_path into from_apm_yml and include it in the cache key. This restores the Value Object invariant, eliminates the mutable-shared-cache risk, and costs only a small API change in the two callers that currently set the field post-construction.

Required:

  • Mutable APMPackage cached before source_path is set; cross-run cache poisoning is possible at src/apm_cli/models/apm_package.py

Nits:

  • _signature_accepts_parent_pkg falls back to True for unintrospectable callables; change fallback to False
  • build_dependency_tree bare except-pass silently discards apm.yml parse errors; replace with _logger.debug(...)
  • DownloadCallback is @runtime_checkable but no isinstance check ever occurs; remove decorator

CLI Logging Expert

Required:

  • Download failure warning is gated to --verbose only; route through DiagnosticCollector for default-verbosity visibility at src/apm_cli/deps/apm_resolver.py
  • Security rejection uses _logger.warning instead of _logger.error; change to error and add fix hint at src/apm_cli/deps/apm_resolver.py

Nits:

  • _rich_error fallback in local_content.py is an anti-pattern; require callers to always pass a logger at src/apm_cli/install/phases/local_content.py
  • exc_info=True on download WARNING emits full traceback in verbose output; move to _logger.debug(..., exc_info=True) at src/apm_cli/deps/apm_resolver.py

DevX UX Expert

Required:

  • Error message not actionable -- no recovery hint at src/apm_cli/install/phases/local_content.py
  • CHANGELOG does not call out the breaking behavior change or provide migration guidance at CHANGELOG.md

Nits:

  • New docs bullet is dense; add a Before/After YAML snippet at docs/src/content/docs/guides/dependencies.md
  • Rejection warning for remote packages lacks fix hint for remote-package author at src/apm_cli/deps/apm_resolver.py

Supply Chain Security Expert

Required:

  • ensure_path_within guard is opt-in; project_root=None silently skips containment check at src/apm_cli/install/phases/local_content.py:116

Nits:

  • Absolute local_path from a remote package bypasses _is_remote_parent early-reject; add analogous rejection branch at src/apm_cli/deps/apm_resolver.py:441
  • shutil.copytree(symlinks=True) preserves in-package symlinks that may escape project root when dereferenced downstream at src/apm_cli/install/phases/local_content.py:161
  • _is_remote_parent heuristic not documented as a security boundary; add comment and adversarial test at src/apm_cli/deps/apm_resolver.py:567

Auth Expert

Inactive -- No auth-specific files touched; changed files are apm_resolver.py, local_content.py, resolve.py, sources.py, apm_package.py, tests, docs -- none of which affect authentication, token management, credential resolution, or host classification.

OSS Growth Hacker

Required:

  • No migration path documented for users whose transitive local_path deps accidentally resolved from root and now break at CHANGELOG.md

Nits:

  • CHANGELOG entry buries user benefit behind implementation detail; lead with the user-facing benefit
  • No repostable one-liner exists in the docs or CHANGELOG
  • Monorepo quickstart or templates not updated to demonstrate the fixed behavior

Verdict computed deterministically: 7 required findings across 5 active panelists. APPROVE iff N == 0. Push a new commit to clear this verdict label automatically.

Note

🔒 Integrity filter blocked 2 items

The following items were blocked because they don't meet the GitHub integrity level.

To allow these resources, lower min-integrity in your GitHub frontmatter:

tools:
  github:
    min-integrity: approved  # merged | approved | unapproved | none

Generated by PR Review Panel for issue #940 · ● 3.4M ·

@danielmeppiel
Copy link
Copy Markdown
Collaborator

Superseded by #1111, which keeps your investigation, fix architecture, and tests intact (preserved via Co-authored-by trailer) and additionally addresses the seven REQUIRED panel findings raised in #940 (review). The conflict surface against main had grown too large for an in-place rebase, so we re-opened on a clean v2 branch off current main. Thanks again for the original work — the anchor rule (base_dir = declaring package's own dir) and the dual-reject of remote-parent local_paths both come from your PR.

Closing in favor of #1111.

danielmeppiel added a commit that referenced this pull request May 2, 2026
…857) (#1111)

* fix(install): anchor transitive local deps on declaring package (#857)

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>

* fix(install): address PR #1111 review comments

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>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Jahanzaib Tayyab <JahanzaibTayyab@users.noreply.github.com>
Co-authored-by: Daniel Meppiel <copilot-rework@github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

panel-rejected Apm-review-panel verdict: REJECT. Removed automatically on next push.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Local relative paths in transitive deps resolve against root consumer instead of declaring package

3 participants