What's the vulnerability?

apm-cli (the apm PyPI package) installs APM packages and their remote dependencies. When it processes a package it enumerates that package's files with bare Path.glob() / Path.rglob() and reads them with read_text(). All three operations transparently follow symbolic links.

A malicious APM dependency can commit a symlink inside its package payload — under .apm/prompts/ or .apm/agents/ — that points at an arbitrary absolute path on the host (for example /etc/passwd). When a victim runs apm install and that dependency is installed, apm-cli dereferences the symlink: it reads the target host file's contents and copies them into the consuming project's installed-package tree.

The result is arbitrary file disclosure: a dependency author can exfiltrate any file readable by the user running apm install into a file inside the victim's project (where it may then be committed, uploaded, or otherwise exposed). No host privileges beyond running apm install are needed.

Root Cause Analysis

# Root Cause Analysis: CVE-2026-45539

## Summary

`apm-cli` (the `apm-cli` PyPI package, versions 0.5.4 through 0.12.4) transparently follows symbolic links when enumerating and reading APM package primitive files (`.prompt.md`, `.agent.md`, `.chatmode.md`). A malicious APM dependency can include a symlink under `.apm/prompts/` or `.apm/agents/` that points to an arbitrary absolute host path (e.g. `/etc/passwd`). During `apm install`, the CLI dereferences the symlink, reads the target file's contents with `Path.read_text()`, and copies those contents into the consuming project's installed-package tree (e.g. `.github/prompts/`). This results in arbitrary file disclosure into the victim's project tree.

## Impact

- **Package/component affected**: `apm-cli` (PyPI package)
- **Affected versions**: `0.5.4` → `0.12.4` (inclusive)
- **Fixed versions**: `0.13.0`
- **Risk level**: High (CVSS 3.1 base 7.4)
- **Consequences**: A malicious dependency author can exfiltrate any file readable by the user running `apm install` into files inside the victim project, where they may be committed, uploaded, or otherwise exposed.

## Root Cause

The vulnerable code paths are in `PromptIntegrator.find_prompt_files()`, `AgentIntegrator.find_agent_files()`, and the various `copy_*()` methods in `src/apm_cli/integration/`. Specifically:

1. **File enumeration** used bare `Path.glob()` and `Path.rglob()` without checking whether entries were symlinks. On Python 3.10+, `Path.glob()` transparently follows symlinks, so a symlink inside `.apm/prompts/` matching `*.prompt.md` would be returned as a valid file path.

2. **File reading** used `Path.read_text()` which dereferences symlinks. When `copy_prompt()` or agent copy methods read the source file, they inadvertently read the symlink target's contents.

3. **No containment check** verified whether a resolved file path stayed within the package directory.

The fix in `v0.13.0` (commit range from `v0.12.4..v0.13.0`, security merge commits `77d1dda` and `f85b9f5`) addresses this by:

- Introducing/refactoring `BaseIntegrator.find_files_by_glob()` to skip symlink entries (`f.is_symlink()`) and hardlinks (`st_nlink > 1`), plus a defense-in-depth `resolve().is_relative_to()` containment guard.
- Updating `PromptIntegrator.find_prompt_files()` and `AgentIntegrator.find_agent_files()` to use `find_files_by_glob()` instead of bare `glob()`/`rglob()`.
- Adding explicit `source.is_symlink()` checks in `copy_prompt()`, `copy_agent()`, and related agent copy methods that raise `ValueError` if a symlink is somehow passed through.

## Reproduction Steps

The reproduction script is located at:
- `repro/reproduction_steps.sh`

What the script does:
1. Creates a scratch workspace under `$ROOT/scratch`.
2. Creates a malicious local APM package (`malicious_pkg`) containing:
   - A legitimate `.apm/prompts/legit.prompt.md` and `.apm/agents/legit.agent.md`
   - A symlink `.apm/prompts/evil.prompt.md -> /path/to/sentinel.txt`
3. Creates a consumer project with `target: copilot` and an empty `dependencies.apm` list.
4. Installs `apm-cli==0.12.4` (vulnerable) in a virtualenv.
5. Runs `apm install ../malicious_pkg` from the consumer project.
6. Verifies that `evil.prompt.md` appears in `.github/prompts/` with the sentinel file's contents (host file disclosure).
7. Installs `apm-cli==0.13.0` (fixed) in the same virtualenv.
8. Resets the consumer project state and re-runs `apm install ../malicious_pkg`.
9. Verifies that `evil.prompt.md` does **not** appear in `.github/prompts/`.

**Expected evidence of reproduction:**
- With `apm-cli==0.12.4`: The install log shows `2 prompts integrated`, and `scratch/consumer/.github/prompts/evil.prompt.md` exists containing the sentinel string.
- With `apm-cli==0.13.0`: The install log shows `1 prompts integrated`, and `evil.prompt.md` is absent from `.github/prompts/`.

## Evidence

Log files generated by the reproduction script:
- `logs/vulnerable_install.log` — Output of `apm install` using the vulnerable build.
- `logs/fixed_install.log` — Output of `apm install` using the fixed build.

Key excerpts from a successful run:

**Vulnerable build (0.12.4):**
```
  [+] ../malicious_pkg (local)
  |-- 2 prompts integrated -> .github/prompts/
  |-- 1 agents integrated -> .github/agents/
```
Post-install file check:
```
FILE: .../.github/prompts/evil.prompt.md
CVE-2026-45539-REPRO-SENTINEL-<timestamp>
```

**Fixed build (0.13.0):**
```
  [+] ../malicious_pkg (local)
  |-- 1 prompts integrated -> .github/prompts/
  |-- 1 agents integrated -> .github/agents/
```
Post-install file check:
- `evil.prompt.md` is **not** present under `.github/prompts/`.

## Recommendations / Next Steps

1. **Upgrade immediately** to `apm-cli>=0.13.0`.
2. **Audit existing projects** for any `apm_modules/` or installed dependencies that may have already leaked host file contents.
3. **Review dependency sources** — only install APM packages from trusted registries or verified local bundles.
4. **Add regression tests** that exercise symlink and hardlink rejection during integration (the project already added `tests/unit/integration/test_symlink_rejection.py` in the fix release).
5. **Consider broader symlink policy** — evaluate whether symlinks should be permitted anywhere in the install pipeline (e.g. during download/unpack) or should be stripped at package ingestion time.

## Additional Notes

- **Idempotency confirmed**: The reproduction script passes two consecutive runs without modification, producing the same observable results each time.
- **Edge cases**: The fix also rejects hardlinks (`st_nlink > 1`) because `Path.resolve()` returns the hardlink's own path, so `is_relative_to()` cannot catch a hardlink to a file outside the package tree.
- **Limitations**: The reproduction uses a local path dependency (`../malicious_pkg`). The same vulnerability would be exploitable via remote dependencies if an attacker could publish a malicious APM package containing symlinks in its `.apm/` subtree.
One Command

Verify with pruva-verify

Run the Pruva CLI to automatically fetch and execute the reproduction script.

pruva-verify REPRO-2026-00136
or pruva-verify GHSA-q5pp-gvjg-h7v4
or pruva-verify CVE-2026-45539
Install: curl -fsSL https://pruva.dev/install.sh | sh

Or Run Manually

1

Download the script

curl -O https://pruva.dev/api/v1/reproductions/REPRO-2026-00136/artifacts/reproduction_steps.sh
2

Make executable

chmod +x reproduction_steps.sh
3

Run the script

./reproduction_steps.sh
Run in a VM, container, or disposable environment. This exploits a real vulnerability.

How Pruva Reproduced This

Watch the AI agent's step-by-step process.

Loading session...

Artifacts

No artifacts available