Skip to content

Commit f85b9f5

Browse files
thesmartshadowdanielmeppielCopilot
authored
Merge commit from fork
Route PromptIntegrator.find_prompt_files() and AgentIntegrator.find_agent_files() through the existing safe BaseIntegrator.find_files_by_glob() helper which rejects symlinks, hardlinks, and paths escaping the package root. Add defense-in-depth guards in copy_prompt(), copy_agent(), _write_codex_agent(), and _write_windsurf_agent_skill() that raise on symlink sources before read_text(). Add regression tests verifying symlink/hardlink rejection. Co-authored-by: danielmeppiel <dmeppiel@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f9b4406 commit f85b9f5

3 files changed

Lines changed: 136 additions & 34 deletions

File tree

src/apm_cli/integration/agent_integrator.py

Lines changed: 20 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ def find_agent_files(self, package_path: Path) -> list[Path]:
2828
2929
Searches in:
3030
- Package root directory (.agent.md and .chatmode.md)
31-
- .apm/agents/ subdirectory (new standard)
31+
- .apm/agents/ subdirectory (new standard, recursive)
3232
- .apm/chatmodes/ subdirectory (legacy)
3333
3434
Args:
@@ -37,31 +37,23 @@ def find_agent_files(self, package_path: Path) -> list[Path]:
3737
Returns:
3838
List[Path]: List of absolute paths to agent files
3939
"""
40-
agent_files = []
41-
42-
# Search in package root
43-
if package_path.exists():
44-
agent_files.extend(package_path.glob("*.agent.md"))
45-
agent_files.extend(package_path.glob("*.chatmode.md")) # Legacy
46-
47-
# Search in .apm/agents/ (new standard)
48-
# Use rglob so agents in subdirectories (e.g. from plugin mapping) are
49-
# still discovered.
40+
files: list[Path] = []
41+
# Flat search in package root
42+
files += self.find_files_by_glob(package_path, "*.agent.md")
43+
files += self.find_files_by_glob(package_path, "*.chatmode.md")
44+
# Recursive search in .apm/agents/ (use ** glob for subdirectories)
5045
apm_agents = package_path / ".apm" / "agents"
5146
if apm_agents.exists():
52-
agent_files.extend(apm_agents.rglob("*.agent.md"))
53-
# Also pick up plain .md files in agents/; plugins may not use
54-
# the .agent.md convention -- the directory name already implies type
55-
for md_file in apm_agents.rglob("*.md"):
56-
if not md_file.name.endswith(".agent.md") and md_file not in agent_files:
57-
agent_files.append(md_file)
58-
59-
# Search in .apm/chatmodes/ (legacy)
47+
files += self.find_files_by_glob(apm_agents, "**/*.agent.md")
48+
# Also pick up plain .md files; the directory name implies type
49+
for f in self.find_files_by_glob(apm_agents, "**/*.md"):
50+
if not f.name.endswith(".agent.md") and f not in files:
51+
files.append(f)
52+
# Flat search in .apm/chatmodes/ (legacy)
6053
apm_chatmodes = package_path / ".apm" / "chatmodes"
6154
if apm_chatmodes.exists():
62-
agent_files.extend(apm_chatmodes.glob("*.chatmode.md"))
63-
64-
return agent_files
55+
files += self.find_files_by_glob(apm_chatmodes, "*.chatmode.md")
56+
return files
6557

6658
# NOTE: find_skill_file(), integrate_skill(), and _generate_skill_agent_content()
6759
# have been REMOVED as part of T5 (skill-strategy.md).
@@ -227,6 +219,8 @@ def copy_agent(self, source: Path, target: Path) -> int:
227219
Returns:
228220
int: Number of links resolved
229221
"""
222+
if source.is_symlink():
223+
raise ValueError(f"Refusing to read symlink source: {source}")
230224
content = source.read_text(encoding="utf-8")
231225
content, links_resolved = self.resolve_links(content, source, target)
232226
target.write_text(content, encoding="utf-8")
@@ -248,6 +242,8 @@ def _write_codex_agent(source: Path, target: Path) -> None:
248242
Parses YAML frontmatter for ``name`` and ``description``, uses
249243
the markdown body as ``developer_instructions``.
250244
"""
245+
if source.is_symlink():
246+
raise ValueError(f"Refusing to read symlink source: {source}")
251247
import toml as _toml
252248

253249
content = source.read_text(encoding="utf-8")
@@ -295,6 +291,8 @@ def _write_windsurf_agent_skill(
295291
diagnostic warning when those fields are dropped.
296292
- Preserves the markdown body verbatim.
297293
"""
294+
if source.is_symlink():
295+
raise ValueError(f"Refusing to read symlink source: {source}")
298296
content = source.read_text(encoding="utf-8")
299297

300298
stem = source.name

src/apm_cli/integration/prompt_integrator.py

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,18 +28,7 @@ def find_prompt_files(self, package_path: Path) -> list[Path]:
2828
Returns:
2929
List[Path]: List of absolute paths to .prompt.md files
3030
"""
31-
prompt_files = []
32-
33-
# Search in package root
34-
if package_path.exists():
35-
prompt_files.extend(package_path.glob("*.prompt.md"))
36-
37-
# Search in .apm/prompts/
38-
apm_prompts = package_path / ".apm" / "prompts"
39-
if apm_prompts.exists():
40-
prompt_files.extend(apm_prompts.glob("*.prompt.md"))
41-
42-
return prompt_files
31+
return self.find_files_by_glob(package_path, "*.prompt.md", subdirs=[".apm/prompts"])
4332

4433
def copy_prompt(self, source: Path, target: Path) -> int:
4534
"""Copy prompt file verbatim with link resolution.
@@ -51,6 +40,8 @@ def copy_prompt(self, source: Path, target: Path) -> int:
5140
Returns:
5241
int: Number of links resolved
5342
"""
43+
if source.is_symlink():
44+
raise ValueError(f"Refusing to read symlink source: {source}")
5445
content = source.read_text(encoding="utf-8")
5546
content, links_resolved = self.resolve_links(content, source, target)
5647
target.write_text(content, encoding="utf-8")
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Regression tests for symlink rejection in prompt/agent integrators.
2+
3+
Verifies that find_prompt_files() and find_agent_files() reject symlinks,
4+
preventing supply-chain file disclosure attacks via malicious APM packages.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import os
10+
from pathlib import Path
11+
12+
import pytest
13+
14+
from apm_cli.integration.agent_integrator import AgentIntegrator
15+
from apm_cli.integration.prompt_integrator import PromptIntegrator
16+
17+
18+
@pytest.fixture
19+
def package_with_symlinks(tmp_path: Path) -> Path:
20+
"""Create a fixture package with symlinks under .apm/ directories."""
21+
pkg = tmp_path / "pkg"
22+
(pkg / ".apm" / "prompts").mkdir(parents=True)
23+
(pkg / ".apm" / "agents").mkdir(parents=True)
24+
(pkg / ".apm" / "chatmodes").mkdir(parents=True)
25+
26+
# Create a sentinel file outside the package
27+
sentinel = tmp_path / "sentinel.txt"
28+
sentinel.write_text("REGRESSION-SENTINEL-CONTENT")
29+
30+
# Create legitimate files
31+
(pkg / ".apm" / "prompts" / "legit.prompt.md").write_text("legit prompt")
32+
(pkg / ".apm" / "agents" / "legit.agent.md").write_text("legit agent")
33+
(pkg / ".apm" / "chatmodes" / "legit.chatmode.md").write_text("legit chatmode")
34+
35+
# Create symlinks pointing outside
36+
(pkg / ".apm" / "prompts" / "leak.prompt.md").symlink_to(sentinel)
37+
(pkg / ".apm" / "agents" / "leak.agent.md").symlink_to(sentinel)
38+
(pkg / ".apm" / "chatmodes" / "leak.chatmode.md").symlink_to(sentinel)
39+
40+
# Create a symlink with absolute path target
41+
(pkg / "abs.agent.md").symlink_to(sentinel)
42+
43+
return pkg
44+
45+
46+
class TestPromptIntegratorSymlinkRejection:
47+
"""Verify PromptIntegrator rejects symlinked files."""
48+
49+
def test_find_prompt_files_excludes_symlinks(self, package_with_symlinks: Path) -> None:
50+
integrator = PromptIntegrator()
51+
result = integrator.find_prompt_files(package_with_symlinks)
52+
53+
# Should find the legit file but not the symlink
54+
assert all(not p.is_symlink() for p in result)
55+
assert not any(p.name == "leak.prompt.md" for p in result)
56+
assert any(p.name == "legit.prompt.md" for p in result)
57+
58+
def test_copy_prompt_rejects_symlink_source(
59+
self, package_with_symlinks: Path, tmp_path: Path
60+
) -> None:
61+
integrator = PromptIntegrator()
62+
symlink_source = package_with_symlinks / ".apm" / "prompts" / "leak.prompt.md"
63+
target = tmp_path / "output.prompt.md"
64+
65+
with pytest.raises(ValueError, match=r"symlink"):
66+
integrator.copy_prompt(symlink_source, target)
67+
68+
69+
class TestAgentIntegratorSymlinkRejection:
70+
"""Verify AgentIntegrator rejects symlinked files."""
71+
72+
def test_find_agent_files_excludes_symlinks(self, package_with_symlinks: Path) -> None:
73+
integrator = AgentIntegrator()
74+
result = integrator.find_agent_files(package_with_symlinks)
75+
76+
# Should find legit files but not symlinks
77+
assert all(not p.is_symlink() for p in result)
78+
assert not any(p.name == "leak.agent.md" for p in result)
79+
assert not any(p.name == "leak.chatmode.md" for p in result)
80+
assert not any(p.name == "abs.agent.md" for p in result)
81+
assert any(p.name == "legit.agent.md" for p in result)
82+
assert any(p.name == "legit.chatmode.md" for p in result)
83+
84+
def test_copy_agent_rejects_symlink_source(
85+
self, package_with_symlinks: Path, tmp_path: Path
86+
) -> None:
87+
integrator = AgentIntegrator()
88+
symlink_source = package_with_symlinks / ".apm" / "agents" / "leak.agent.md"
89+
target = tmp_path / "output.agent.md"
90+
91+
with pytest.raises(ValueError, match=r"symlink"):
92+
integrator.copy_agent(symlink_source, target)
93+
94+
95+
class TestHardlinkRejection:
96+
"""Verify integrators reject hardlinked files."""
97+
98+
@pytest.mark.skipif(os.name == "nt", reason="Hardlinks may require privileges on Windows")
99+
def test_find_prompt_files_excludes_hardlinks(self, tmp_path: Path) -> None:
100+
pkg = tmp_path / "pkg"
101+
(pkg / ".apm" / "prompts").mkdir(parents=True)
102+
103+
# Create a file and a hardlink to it
104+
original = tmp_path / "original.txt"
105+
original.write_text("hardlink content")
106+
hardlink = pkg / ".apm" / "prompts" / "linked.prompt.md"
107+
os.link(original, hardlink)
108+
109+
integrator = PromptIntegrator()
110+
result = integrator.find_prompt_files(pkg)
111+
112+
# Hardlink has st_nlink > 1, should be rejected
113+
assert not any(p.name == "linked.prompt.md" for p in result)

0 commit comments

Comments
 (0)