Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Slash commands installed from APM packages now surface argument hints in Claude Code -- `apm install` automatically maps prompt `input:` to Claude's `arguments:` front-matter, rewrites `${input:name}` references to `$name`, and auto-generates `argument-hint`. Argument names are validated against an allowlist to prevent YAML injection from third-party packages, and the mapping is reported at install time. (#1039)

### Fixed

- **Transitive `local_path` dependencies now anchor on the declaring `apm.yml`.** Relative paths declared inside a transitive package (e.g. `../sibling`) resolve against that package's directory, not the root consumer, matching what a developer reading the file expects. Hardening: relative `local_path` declarations found inside remotely-fetched packages are now rejected, `_copy_local_package` enforces that the resolved source lies within the project root via `ensure_path_within`, and silent download failures during transitive resolution are now logged for `--verbose` runs. Thanks @JahanzaibTayyab. (#940, closes #857)

## [0.11.0] - 2026-04-29

### Added
Expand Down
5 changes: 3 additions & 2 deletions docs/src/content/docs/guides/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ dependencies:
- gitlab.com/acme/repo/prompts/code-review.prompt.md

# Local path (for development / monorepo workflows)
- ./packages/my-shared-skills # relative to project root
- ./packages/my-shared-skills # relative to this apm.yml's directory
- /home/user/repos/my-ai-package # absolute path

# Object format: git URL + sub-path / ref / alias
Expand Down Expand Up @@ -288,7 +288,7 @@ Or declare them in `apm.yml`:
```yaml
dependencies:
apm:
- ./packages/my-shared-skills # relative to project root
- ./packages/my-shared-skills # relative to this apm.yml's directory
- /home/user/repos/my-ai-package # absolute path
- microsoft/apm-sample-package # remote (can be mixed)
```
Expand All @@ -298,6 +298,7 @@ dependencies:
- Local packages are validated the same as remote packages (must have `apm.yml` or `SKILL.md`)
- `apm compile` works identically regardless of dependency source
- Transitive dependencies are resolved recursively (local packages can depend on remote packages)
- **Relative paths anchor on the declaring `apm.yml`** -- a local dep listed inside a nested package (e.g. `../sibling-package` declared in `packages/foo/apm.yml`) resolves against that package's directory, not the root consumer. This matches every other package manager (npm, pip, cargo) and lets sibling packages compose. Use absolute paths if you want anchor-independent behavior.

**Re-install behavior:** Local deps are always re-copied on `apm install` since there is no commit SHA to cache against. This ensures you always get the latest local changes.

Expand Down
8 changes: 8 additions & 0 deletions packages/apm-guide/.apm/skills/apm-usage/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ dependencies:
- ../sibling-repo/my-package
```

Relative `local_path` entries (`./...`, `../...`) anchor on the directory of
the `apm.yml` that *declares* them, not on the root project. So a transitive
package at `packages/handbook-agents/apm.yml` declaring `../editorial-pipeline`
resolves to `packages/editorial-pipeline/`, matching what a developer reading
the file would expect. Relative `local_path` declarations found inside
remotely-fetched packages are rejected -- only the root project's `apm.yml`
may use them.

### Custom git ports

Non-default git ports are preserved on `https://`, `http://`, and `ssh://` URLs
Expand Down
247 changes: 224 additions & 23 deletions src/apm_cli/deps/apm_resolver.py

Large diffs are not rendered by default.

40 changes: 36 additions & 4 deletions src/apm_cli/install/phases/local_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@
from pathlib import Path

from apm_cli.utils.console import _rich_error
from apm_cli.utils.path_security import safe_rmtree
from apm_cli.utils.path_security import (
PathTraversalError,
ensure_path_within,
safe_rmtree,
)


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -75,14 +79,28 @@ def _has_local_apm_content(project_root):
# ---------------------------------------------------------------------------


def _copy_local_package(dep_ref, install_path, project_root, logger=None):
def _copy_local_package(
dep_ref, install_path, base_dir, logger=None, project_root=None
):
"""Copy a local package to apm_modules/.

Args:
dep_ref: DependencyReference with is_local=True
install_path: Target path under apm_modules/
project_root: Project root for resolving relative paths
base_dir: Directory used to resolve a relative ``dep_ref.local_path``.
For direct deps from the root project this is the project root;
for transitive deps it is the source directory of the package
whose apm.yml declared *dep_ref* (#857).
logger: Optional CommandLogger for structured output
project_root: Optional path to the root project. When supplied, the
resolved local source is asserted to lie within ``project_root``
via :func:`ensure_path_within`, so a transitive declaration like
``../../../etc/passwd`` is rejected even though ``base_dir`` is
now a transitive parent's source directory rather than the root
(#940). Note: we deliberately do *not* call
``validate_path_segments`` on ``dep_ref.local_path`` -- that
would reject the legitimate one-level-up form ``../sibling``
which is the very pattern this PR enables.

Returns:
install_path on success, None on failure
Expand All @@ -91,10 +109,24 @@ def _copy_local_package(dep_ref, install_path, project_root, logger=None):

local = Path(dep_ref.local_path).expanduser()
if not local.is_absolute():
local = (project_root / local).resolve()
local = (base_dir / local).resolve()
else:
local = local.resolve()

if project_root is not None:
try:
ensure_path_within(local, project_root)
except PathTraversalError as exc:
msg = (
f"Refusing to copy local package: {dep_ref.local_path!r} "
f"resolves outside the project root ({exc})."
)
if logger:
logger.error(msg)
else:
_rich_error(msg)
return None

if not local.is_dir():
msg = f"Local package path does not exist: {dep_ref.local_path}"
if logger:
Expand Down
21 changes: 19 additions & 2 deletions src/apm_cli/install/phases/resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,14 +121,21 @@ def run(ctx: "InstallContext") -> None:
logger = ctx.logger
verbose = ctx.verbose

def download_callback(dep_ref, modules_dir, parent_chain=""):
def download_callback(dep_ref, modules_dir, parent_chain="", parent_pkg=None):
"""Download a package during dependency resolution.

Args:
dep_ref: The dependency to download.
modules_dir: Target apm_modules directory.
parent_chain: Human-readable breadcrumb (e.g. "root > mid")
showing which dependency path led to this transitive dep.
parent_pkg: The APMPackage that declared *dep_ref* (None for
direct deps from the root project). For local deps, the
parent's ``source_path`` becomes the base directory for
resolving the relative path -- so a transitive dep like
``../sibling`` declared inside a package nested several
directories deep resolves against the package's own
location, not the root consumer (#857).
"""
install_path = dep_ref.get_install_path(modules_dir)
if install_path.exists():
Expand All @@ -142,8 +149,18 @@ def download_callback(dep_ref, modules_dir, parent_chain=""):
# so use .add() rather than dict-style assignment.
callback_failures.add(dep_ref.get_unique_key())
return None
# Anchor relative paths on the *declaring* package's source
# directory when available (#857). Falls back to
# ``project_root`` for direct deps and for parents that
# predate the source_path field.
base_dir = (
parent_pkg.source_path
if parent_pkg is not None and parent_pkg.source_path is not None
else project_root
)
Comment thread
JahanzaibTayyab marked this conversation as resolved.
result_path = _copy_local_package(
dep_ref, install_path, project_root, logger=logger
dep_ref, install_path, base_dir, logger=logger,
project_root=project_root,
)
if result_path:
callback_downloaded[dep_ref.get_unique_key()] = None
Expand Down
3 changes: 2 additions & 1 deletion src/apm_cli/install/sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,8 @@ def acquire(self) -> Optional[Materialization]:
return None

result_path = _copy_local_package(
dep_ref, install_path, ctx.project_root, logger=logger
dep_ref, install_path, ctx.project_root, logger=logger,
project_root=ctx.project_root,
)
if not result_path:
diagnostics.error(
Expand Down
6 changes: 6 additions & 0 deletions src/apm_cli/models/apm_package.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ class APMPackage:
dev_dependencies: Optional[Dict[str, List[Union[DependencyReference, str, dict]]]] = None
scripts: Optional[Dict[str, str]] = None
package_path: Optional[Path] = None # Local path to package
# Absolute on-disk directory holding this package's apm.yml. Used to
# resolve relative ``local_path`` dependencies declared in this package's
# apm.yml (#857). For local deps this is the *original* source directory,
# not the apm_modules/_local/ copy. For remote deps and the root project
# this matches package_path.
source_path: Optional[Path] = None
target: Optional[Union[str, List[str]]] = None # Target agent(s): single string or list (applies to compile and install)
type: Optional[PackageContentType] = None # Package content type: instructions, skill, hybrid, or prompts
includes: Optional[Union[str, List[str]]] = None # Include-only manifest: 'auto' or list of repo paths
Expand Down
Loading
Loading