Skip to content

Commit 3a20dd9

Browse files
fix: reject symlinks in file discovery and resolution (#596)
Reject symlinked primitive files outright via is_symlink() checks at all file discovery and resolution choke points. This matches the documented security policy ('symlinks are never followed') and prevents symlink- based traversal attacks from malicious packages. Protected paths: - PromptCompiler._resolve_prompt_file (prompt compilation/preview) - ScriptRunner._discover_prompt_file (prompt discovery) - find_primitive_files (instruction/agent/context discovery) - BaseIntegrator.find_files_by_glob (integrator file discovery) - HookIntegrator.find_hook_files (hook JSON discovery) - 5 new security tests with Windows symlink portability guards - Updated security docs to reflect full symlink rejection coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 3752b60 commit 3a20dd9

File tree

6 files changed

+229
-18
lines changed

6 files changed

+229
-18
lines changed

docs/src/content/docs/enterprise/security.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -175,12 +175,15 @@ A path must pass all three checks. Failure on any check prevents the file from b
175175

176176
### Symlink handling
177177

178-
Symlinks are never followed during artifact operations:
178+
Symlinks are never followed during file discovery or artifact operations:
179179

180-
- **Tree copy operations** skip symlinks entirely — they are excluded from the copy via an ignore filter.
180+
- **Primitive discovery** (instructions, agents, prompts, contexts, skills) rejects symlinked files during glob-based file enumeration. Symlinks are silently skipped.
181+
- **Prompt resolution** (`apm preview`, `apm run`) rejects symlinked `.prompt.md` files with an explicit error message.
182+
- **Integrator file discovery** (agents, instructions, prompts, skills, hooks) rejects symlinked files via `is_symlink()` checks in `find_files_by_glob` and `find_hook_files`.
183+
- **Tree copy operations** skip symlinks entirely -- they are excluded from the copy via an ignore filter.
181184
- **MCP configuration files** that are symlinks are rejected with a warning and not parsed.
182185
- **Manifest parsing** requires files to pass both `.is_file()` and `not .is_symlink()` checks.
183-
- **Archive creation** `apm pack` excludes symlinks from bundled archives. Packaged artifacts contain no symbolic links, preventing symlink-based escape attacks in distributed bundles.
186+
- **Archive creation** -- `apm pack` excludes symlinks from bundled archives. Packaged artifacts contain no symbolic links, preventing symlink-based escape attacks in distributed bundles.
184187

185188
This prevents symlink-based attacks that could escape allowed directories or cause APM to read or write outside the project root.
186189

src/apm_cli/core/script_runner.py

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -546,22 +546,24 @@ def _discover_prompt_file(self, name: str) -> Optional[Path]:
546546
]
547547

548548
for path in local_search_paths:
549-
if path.exists():
549+
if path.exists() and not path.is_symlink():
550550
return path
551551

552552
# 2. Search in dependencies and detect collisions
553553
apm_modules = Path("apm_modules")
554554
if apm_modules.exists():
555555
# Collect ALL .prompt.md matches to detect collisions
556-
matches = list(apm_modules.rglob(search_name))
556+
raw_matches = list(apm_modules.rglob(search_name))
557557

558558
# Also search for SKILL.md in directories matching the name
559-
# e.g., name="architecture-blueprint-generator" -> find */architecture-blueprint-generator/SKILL.md
560559
for skill_dir in apm_modules.rglob(name):
561560
if skill_dir.is_dir():
562561
skill_file = skill_dir / "SKILL.md"
563562
if skill_file.exists():
564-
matches.append(skill_file)
563+
raw_matches.append(skill_file)
564+
565+
# Filter out symlinks
566+
matches = [m for m in raw_matches if not m.is_symlink()]
565567

566568
if len(matches) == 0:
567569
return None
@@ -945,47 +947,49 @@ def compile(self, prompt_file: str, params: Dict[str, str]) -> str:
945947
def _resolve_prompt_file(self, prompt_file: str) -> Path:
946948
"""Resolve prompt file path, checking local directory first, then common directories, then dependencies.
947949
950+
Symlinks are rejected outright to prevent traversal attacks.
951+
948952
Args:
949953
prompt_file: Relative path to the .prompt.md file
950954
951955
Returns:
952956
Path: Resolved path to the prompt file
953957
954958
Raises:
955-
FileNotFoundError: If prompt file is not found in local or dependency modules
959+
FileNotFoundError: If prompt file is not found or is a symlink
956960
"""
957961
prompt_path = Path(prompt_file)
958962

959963
# First check if it exists in current directory (local)
960964
if prompt_path.exists():
965+
if prompt_path.is_symlink():
966+
raise FileNotFoundError(
967+
f"Prompt file '{prompt_file}' is a symlink. "
968+
f"Symlinks are not allowed for security reasons."
969+
)
961970
return prompt_path
962971

963972
# Check in common project directories
964973
common_dirs = [".github/prompts", ".apm/prompts"]
965974
for common_dir in common_dirs:
966975
common_path = Path(common_dir) / prompt_file
967-
if common_path.exists():
976+
if common_path.exists() and not common_path.is_symlink():
968977
return common_path
969978

970979
# If not found locally, search in dependency modules
971980
apm_modules_dir = Path("apm_modules")
972981
if apm_modules_dir.exists():
973-
# Search all dependency directories for the prompt file
974-
# Handle org/repo directory structure (e.g., apm_modules/microsoft/apm-sample-package/)
975982
for org_dir in apm_modules_dir.iterdir():
976983
if org_dir.is_dir() and not org_dir.name.startswith("."):
977-
# Iterate through repos within the org
978984
for repo_dir in org_dir.iterdir():
979985
if repo_dir.is_dir() and not repo_dir.name.startswith("."):
980-
# Check in the root of the repository
981986
dep_prompt_path = repo_dir / prompt_file
982-
if dep_prompt_path.exists():
987+
if dep_prompt_path.exists() and not dep_prompt_path.is_symlink():
983988
return dep_prompt_path
984989

985-
# Also check in common subdirectories
986990
for subdir in ["prompts", ".", "workflows"]:
987991
sub_prompt_path = repo_dir / subdir / prompt_file
988-
if sub_prompt_path.exists():
992+
if sub_prompt_path.exists() and not sub_prompt_path.is_symlink():
989993
return sub_prompt_path
990994

991995
# If still not found, raise an error with helpful message

src/apm_cli/integration/base_integrator.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,8 @@ def find_files_by_glob(
409409
) -> List[Path]:
410410
"""Search *package_path* (and optional subdirectories) for *pattern*.
411411
412+
Symlinks are rejected outright to prevent traversal attacks.
413+
412414
Args:
413415
package_path: Root of the installed package.
414416
pattern: Glob pattern (e.g. ``"*.prompt.md"``).
@@ -429,6 +431,8 @@ def find_files_by_glob(
429431
if not d.exists():
430432
continue
431433
for f in sorted(d.glob(pattern)):
434+
if f.is_symlink():
435+
continue
432436
resolved = f.resolve()
433437
if resolved not in seen:
434438
seen.add(resolved)

src/apm_cli/integration/hook_integrator.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,8 @@ def find_hook_files(self, package_path: Path) -> List[Path]:
151151
apm_hooks = package_path / ".apm" / "hooks"
152152
if apm_hooks.exists():
153153
for f in sorted(apm_hooks.glob("*.json")):
154+
if f.is_symlink():
155+
continue
154156
resolved = f.resolve()
155157
if resolved not in seen:
156158
seen.add(resolved)
@@ -160,6 +162,8 @@ def find_hook_files(self, package_path: Path) -> List[Path]:
160162
hooks_dir = package_path / "hooks"
161163
if hooks_dir.exists():
162164
for f in sorted(hooks_dir.glob("*.json")):
165+
if f.is_symlink():
166+
continue
163167
resolved = f.resolve()
164168
if resolved not in seen:
165169
seen.add(resolved)

src/apm_cli/primitives/discovery.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,9 @@ def _discover_skill_in_directory(directory: Path, collection: PrimitiveCollectio
375375
def find_primitive_files(base_dir: str, patterns: List[str]) -> List[Path]:
376376
"""Find primitive files matching the given patterns.
377377
378+
Symlinks are rejected outright to prevent symlink-based traversal
379+
attacks from malicious packages.
380+
378381
Args:
379382
base_dir (str): Base directory to search in.
380383
patterns (List[str]): List of glob patterns to match.
@@ -402,10 +405,15 @@ def find_primitive_files(base_dir: str, patterns: List[str]) -> List[Path]:
402405
seen.add(abs_path)
403406
unique_files.append(Path(abs_path))
404407

405-
# Filter out directories and ensure files are readable
408+
# Filter out directories, symlinks, and unreadable files
406409
valid_files = []
407410
for file_path in unique_files:
408-
if file_path.is_file() and _is_readable(file_path):
411+
if not file_path.is_file():
412+
continue
413+
if file_path.is_symlink():
414+
logger.debug("Rejected symlink: %s", file_path)
415+
continue
416+
if _is_readable(file_path):
409417
valid_files.append(file_path)
410418

411419
return valid_files
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
"""Tests for symlink containment enforcement across APM subsystems.
2+
3+
Validates that symlinked primitive files are rejected at discovery and
4+
resolution time, preventing arbitrary local file reads.
5+
"""
6+
7+
import json
8+
import os
9+
import tempfile
10+
import shutil
11+
import unittest
12+
from pathlib import Path
13+
14+
15+
def _try_symlink(link: Path, target: Path):
16+
"""Create a symlink or skip the test on platforms that don't support it."""
17+
try:
18+
link.symlink_to(target)
19+
except OSError:
20+
raise unittest.SkipTest("Symlinks not supported on this platform")
21+
22+
23+
class TestPromptCompilerSymlinkContainment(unittest.TestCase):
24+
"""PromptCompiler._resolve_prompt_file rejects external symlinks."""
25+
26+
def setUp(self):
27+
self.tmpdir = tempfile.mkdtemp()
28+
self.project = Path(self.tmpdir) / "project"
29+
self.project.mkdir()
30+
self.outside = Path(self.tmpdir) / "outside"
31+
self.outside.mkdir()
32+
# Create a file outside the project
33+
self.secret = self.outside / "secret.txt"
34+
self.secret.write_text("sensitive-data", encoding="utf-8")
35+
# Create apm.yml so the project is valid
36+
(self.project / "apm.yml").write_text(
37+
"name: test\nversion: 1.0.0\n", encoding="utf-8"
38+
)
39+
40+
def tearDown(self):
41+
shutil.rmtree(self.tmpdir, ignore_errors=True)
42+
43+
def test_symlinked_prompt_outside_project_rejected(self):
44+
"""Symlinked .prompt.md is rejected with clear error message."""
45+
from apm_cli.core.script_runner import PromptCompiler
46+
47+
prompts_dir = self.project / ".apm" / "prompts"
48+
prompts_dir.mkdir(parents=True)
49+
symlink = prompts_dir / "evil.prompt.md"
50+
_try_symlink(symlink, self.secret)
51+
52+
compiler = PromptCompiler()
53+
old_cwd = os.getcwd()
54+
try:
55+
os.chdir(self.project)
56+
with self.assertRaises(FileNotFoundError) as ctx:
57+
compiler._resolve_prompt_file(".apm/prompts/evil.prompt.md")
58+
self.assertIn("symlink", str(ctx.exception).lower())
59+
finally:
60+
os.chdir(old_cwd)
61+
62+
def test_normal_prompt_within_project_allowed(self):
63+
"""Non-symlinked prompt files within the project are allowed."""
64+
from apm_cli.core.script_runner import PromptCompiler
65+
66+
prompts_dir = self.project / ".apm" / "prompts"
67+
prompts_dir.mkdir(parents=True)
68+
prompt = prompts_dir / "safe.prompt.md"
69+
prompt.write_text("# Safe prompt", encoding="utf-8")
70+
71+
compiler = PromptCompiler()
72+
old_cwd = os.getcwd()
73+
try:
74+
os.chdir(self.project)
75+
result = compiler._resolve_prompt_file(".apm/prompts/safe.prompt.md")
76+
self.assertTrue(result.exists())
77+
finally:
78+
os.chdir(old_cwd)
79+
80+
81+
class TestPrimitiveDiscoverySymlinkContainment(unittest.TestCase):
82+
"""find_primitive_files rejects symlinks outside base directory."""
83+
84+
def setUp(self):
85+
self.tmpdir = tempfile.mkdtemp()
86+
self.project = Path(self.tmpdir) / "project"
87+
self.project.mkdir()
88+
self.outside = Path(self.tmpdir) / "outside"
89+
self.outside.mkdir()
90+
self.secret = self.outside / "leak.instructions.md"
91+
self.secret.write_text("---\napplyTo: '**'\n---\nLeaked!", encoding="utf-8")
92+
93+
def tearDown(self):
94+
shutil.rmtree(self.tmpdir, ignore_errors=True)
95+
96+
def test_symlinked_instruction_outside_base_rejected(self):
97+
"""Symlinked .instructions.md outside base_dir is filtered out."""
98+
from apm_cli.primitives.discovery import find_primitive_files
99+
100+
instructions_dir = self.project / ".github" / "instructions"
101+
instructions_dir.mkdir(parents=True)
102+
symlink = instructions_dir / "evil.instructions.md"
103+
_try_symlink(symlink, self.secret)
104+
105+
# Also add a normal file
106+
normal = instructions_dir / "safe.instructions.md"
107+
normal.write_text("---\napplyTo: '**'\n---\nSafe", encoding="utf-8")
108+
109+
results = find_primitive_files(
110+
str(self.project),
111+
[".github/instructions/*.instructions.md"],
112+
)
113+
names = [f.name for f in results]
114+
self.assertIn("safe.instructions.md", names)
115+
self.assertNotIn("evil.instructions.md", names)
116+
117+
118+
class TestBaseIntegratorSymlinkContainment(unittest.TestCase):
119+
"""BaseIntegrator.find_files_by_glob rejects external symlinks."""
120+
121+
def setUp(self):
122+
self.tmpdir = tempfile.mkdtemp()
123+
self.pkg = Path(self.tmpdir) / "pkg"
124+
self.pkg.mkdir()
125+
self.outside = Path(self.tmpdir) / "outside"
126+
self.outside.mkdir()
127+
self.secret = self.outside / "leak.agent.md"
128+
self.secret.write_text("# Leaked agent", encoding="utf-8")
129+
130+
def tearDown(self):
131+
shutil.rmtree(self.tmpdir, ignore_errors=True)
132+
133+
def test_symlinked_agent_outside_package_rejected(self):
134+
"""Symlinked .agent.md outside package dir is filtered out."""
135+
from apm_cli.integration.base_integrator import BaseIntegrator
136+
137+
agents_dir = self.pkg / ".apm" / "agents"
138+
agents_dir.mkdir(parents=True)
139+
symlink = agents_dir / "evil.agent.md"
140+
_try_symlink(symlink, self.secret)
141+
142+
normal = agents_dir / "safe.agent.md"
143+
normal.write_text("# Safe agent", encoding="utf-8")
144+
145+
results = BaseIntegrator.find_files_by_glob(
146+
self.pkg, "*.agent.md", subdirs=[".apm/agents"],
147+
)
148+
names = [f.name for f in results]
149+
self.assertIn("safe.agent.md", names)
150+
self.assertNotIn("evil.agent.md", names)
151+
152+
153+
class TestHookIntegratorSymlinkContainment(unittest.TestCase):
154+
"""HookIntegrator.find_hook_files rejects external symlinks."""
155+
156+
def setUp(self):
157+
self.tmpdir = tempfile.mkdtemp()
158+
self.pkg = Path(self.tmpdir) / "pkg"
159+
self.pkg.mkdir()
160+
self.outside = Path(self.tmpdir) / "outside"
161+
self.outside.mkdir()
162+
self.secret = self.outside / "evil.json"
163+
self.secret.write_text(json.dumps({"hooks": {}}), encoding="utf-8")
164+
165+
def tearDown(self):
166+
shutil.rmtree(self.tmpdir, ignore_errors=True)
167+
168+
def test_symlinked_hook_json_outside_package_rejected(self):
169+
"""Symlinked hook JSON outside package dir is filtered out."""
170+
from apm_cli.integration.hook_integrator import HookIntegrator
171+
172+
hooks_dir = self.pkg / ".apm" / "hooks"
173+
hooks_dir.mkdir(parents=True)
174+
symlink = hooks_dir / "evil.json"
175+
_try_symlink(symlink, self.secret)
176+
177+
normal = hooks_dir / "safe.json"
178+
normal.write_text(json.dumps({"hooks": {}}), encoding="utf-8")
179+
180+
integrator = HookIntegrator()
181+
results = integrator.find_hook_files(self.pkg)
182+
names = [f.name for f in results]
183+
self.assertIn("safe.json", names)
184+
self.assertNotIn("evil.json", names)
185+
186+
187+
if __name__ == "__main__":
188+
unittest.main()

0 commit comments

Comments
 (0)