Skip to content

Commit 569d18a

Browse files
authored
fix(agents): block directory traversal in command write paths (#2229) (#2296)
Extend the alias containment guard from b67b285 to the two remaining write paths that derive filenames from free-form command/alias names: - Primary command write in CommandRegistrar.register_commands() - CommandRegistrar.write_copilot_prompt() Consolidate the check into a shared _ensure_inside() helper. Per maintainer guidance on #2229, use a lexical (os.path.normpath + Path.is_relative_to) containment check rather than resolve() so `..` / absolute-path traversal is rejected while intentionally symlinked sub-directories under an agent's commands directory (e.g. .claude/skills/shared -> /team/shared-skills) keep working for existing extension setups. Add 22 parametrised regression cases covering traversal payloads on primary commands, aliases, and the Copilot companion prompt, plus a positive case that confirms symlinked sub-directories remain supported.
1 parent f10fd07 commit 569d18a

File tree

2 files changed

+230
-6
lines changed

2 files changed

+230
-6
lines changed

src/specify_cli/agents.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
command files into agent-specific directories in the correct format.
77
"""
88

9+
import os
910
from pathlib import Path
1011
from typing import Dict, List, Any
1112

@@ -399,6 +400,28 @@ def _compute_output_name(
399400

400401
return f"speckit-{short_name}"
401402

403+
@staticmethod
404+
def _ensure_inside(candidate: Path, base: Path) -> None:
405+
"""Validate that a write target stays within the expected base directory.
406+
407+
Uses lexical normalization so traversal via ``..`` or absolute paths is
408+
rejected while intentionally symlinked sub-directories remain
409+
supported.
410+
411+
Args:
412+
candidate: Path that will be written.
413+
base: Directory the write must remain within.
414+
415+
Raises:
416+
ValueError: If the normalized candidate path escapes ``base``.
417+
"""
418+
normalized = Path(os.path.normpath(candidate))
419+
base_normalized = Path(os.path.normpath(base))
420+
if not normalized.is_relative_to(base_normalized):
421+
raise ValueError(
422+
f"Output path {candidate!r} escapes directory {base!r}"
423+
)
424+
402425
def register_commands(
403426
self,
404427
agent_name: str,
@@ -485,6 +508,7 @@ def register_commands(
485508
raise ValueError(f"Unsupported format: {agent_config['format']}")
486509

487510
dest_file = commands_dir / f"{output_name}{agent_config['extension']}"
511+
self._ensure_inside(dest_file, commands_dir)
488512
dest_file.parent.mkdir(parents=True, exist_ok=True)
489513
dest_file.write_text(output, encoding="utf-8")
490514

@@ -550,12 +574,7 @@ def register_commands(
550574
alias_file = (
551575
commands_dir / f"{alias_output_name}{agent_config['extension']}"
552576
)
553-
try:
554-
alias_file.resolve().relative_to(commands_dir.resolve())
555-
except ValueError:
556-
raise ValueError(
557-
f"Alias output path escapes commands directory: {alias_file!r}"
558-
)
577+
self._ensure_inside(alias_file, commands_dir)
559578
alias_file.parent.mkdir(parents=True, exist_ok=True)
560579
alias_file.write_text(alias_output, encoding="utf-8")
561580
if agent_name == "copilot":
@@ -575,6 +594,7 @@ def write_copilot_prompt(project_root: Path, cmd_name: str) -> None:
575594
prompts_dir = project_root / ".github" / "prompts"
576595
prompts_dir.mkdir(parents=True, exist_ok=True)
577596
prompt_file = prompts_dir / f"{cmd_name}.prompt.md"
597+
CommandRegistrar._ensure_inside(prompt_file, prompts_dir)
578598
prompt_file.write_text(f"---\nagent: {cmd_name}\n---\n", encoding="utf-8")
579599

580600
def register_commands_for_all_agents(
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
"""Tests for CommandRegistrar directory traversal guards around issue #2229."""
2+
3+
import errno
4+
from pathlib import Path
5+
6+
import pytest
7+
8+
from specify_cli.agents import CommandRegistrar
9+
10+
11+
TRAVERSAL_PAYLOADS = [
12+
"../pwned",
13+
"../../etc/passwd",
14+
"subdir/../../escape",
15+
"/absolute/evil",
16+
]
17+
18+
19+
def _write_source(ext_dir: Path) -> Path:
20+
ext_dir.mkdir(parents=True, exist_ok=True)
21+
(ext_dir / "commands").mkdir(exist_ok=True)
22+
(ext_dir / "commands" / "cmd.md").write_text(
23+
"---\ndescription: test\n---\n\nbody\n", encoding="utf-8"
24+
)
25+
return ext_dir
26+
27+
28+
def _cmd(name: str, aliases: list[str] | None = None) -> dict[str, object]:
29+
return {
30+
"name": name,
31+
"file": "commands/cmd.md",
32+
"aliases": list(aliases or []),
33+
}
34+
35+
36+
def _project_and_source(tmp_path):
37+
project = tmp_path / "project"
38+
project.mkdir()
39+
ext_dir = _write_source(tmp_path / "ext-src")
40+
return project, ext_dir
41+
42+
43+
def _assert_no_stray_files(tmp_root: Path, marker: str) -> None:
44+
"""Fail if a file matching ``marker`` exists outside the project tree."""
45+
stray = [
46+
p for p in tmp_root.rglob("*")
47+
if p.is_file() and marker in p.name and "project" not in p.parts
48+
]
49+
assert stray == [], (
50+
f"Traversal payload leaked files outside the project tree: {stray}"
51+
)
52+
53+
54+
class TestPrimaryCommandTraversal:
55+
"""Primary command names must not escape the agent's commands directory."""
56+
57+
@pytest.mark.parametrize("bad_name", TRAVERSAL_PAYLOADS)
58+
def test_gemini_rejects_traversal_in_primary_name(self, tmp_path, bad_name):
59+
project, ext_dir = _project_and_source(tmp_path)
60+
(project / ".gemini" / "commands").mkdir(parents=True)
61+
62+
registrar = CommandRegistrar()
63+
with pytest.raises(ValueError, match="escapes|outside|Invalid"):
64+
registrar.register_commands(
65+
"gemini", [_cmd(bad_name)], "myext", ext_dir, project
66+
)
67+
68+
_assert_no_stray_files(tmp_path, Path(bad_name).name.replace("/", ""))
69+
70+
@pytest.mark.parametrize("bad_name", TRAVERSAL_PAYLOADS)
71+
def test_copilot_rejects_traversal_in_primary_name(self, tmp_path, bad_name):
72+
project, ext_dir = _project_and_source(tmp_path)
73+
(project / ".github" / "agents").mkdir(parents=True)
74+
(project / ".github" / "prompts").mkdir(parents=True)
75+
76+
registrar = CommandRegistrar()
77+
with pytest.raises(ValueError, match="escapes|outside|Invalid"):
78+
registrar.register_commands(
79+
"copilot", [_cmd(bad_name)], "myext", ext_dir, project
80+
)
81+
82+
_assert_no_stray_files(tmp_path, Path(bad_name).name.replace("/", ""))
83+
84+
85+
class TestAliasTraversal:
86+
"""Free-form aliases must not escape commands_dir (regression for b67b285)."""
87+
88+
@pytest.mark.parametrize("bad_alias", TRAVERSAL_PAYLOADS)
89+
def test_gemini_rejects_traversal_in_alias(self, tmp_path, bad_alias):
90+
project, ext_dir = _project_and_source(tmp_path)
91+
(project / ".gemini" / "commands").mkdir(parents=True)
92+
93+
registrar = CommandRegistrar()
94+
with pytest.raises(ValueError, match="escapes|outside|Invalid"):
95+
registrar.register_commands(
96+
"gemini",
97+
[_cmd("speckit.myext.ok", [bad_alias])],
98+
"myext",
99+
ext_dir,
100+
project,
101+
)
102+
103+
_assert_no_stray_files(tmp_path, Path(bad_alias).name.replace("/", ""))
104+
105+
@pytest.mark.parametrize("bad_alias", TRAVERSAL_PAYLOADS)
106+
def test_copilot_rejects_traversal_in_alias(self, tmp_path, bad_alias):
107+
project, ext_dir = _project_and_source(tmp_path)
108+
(project / ".github" / "agents").mkdir(parents=True)
109+
(project / ".github" / "prompts").mkdir(parents=True)
110+
111+
registrar = CommandRegistrar()
112+
with pytest.raises(ValueError, match="escapes|outside|Invalid"):
113+
registrar.register_commands(
114+
"copilot",
115+
[_cmd("speckit.myext.ok", [bad_alias])],
116+
"myext",
117+
ext_dir,
118+
project,
119+
)
120+
121+
_assert_no_stray_files(tmp_path, Path(bad_alias).name.replace("/", ""))
122+
123+
124+
class TestCopilotPromptTraversal:
125+
"""`write_copilot_prompt` is a public static method — guard it directly."""
126+
127+
@pytest.mark.parametrize("bad_name", TRAVERSAL_PAYLOADS)
128+
def test_rejects_traversal_names(self, tmp_path, bad_name):
129+
project = tmp_path / "project"
130+
(project / ".github" / "prompts").mkdir(parents=True)
131+
132+
with pytest.raises(ValueError, match="escapes|outside|Invalid"):
133+
CommandRegistrar.write_copilot_prompt(project, bad_name)
134+
135+
_assert_no_stray_files(tmp_path, Path(bad_name).name.replace("/", ""))
136+
137+
138+
class TestSafeRegistration:
139+
"""Positive regression — well-formed names continue to register."""
140+
141+
def test_symlinked_subdir_under_commands_dir_is_preserved(self, tmp_path):
142+
"""Lexical check must not block legitimately symlinked sub-directories.
143+
144+
Teams sometimes symlink shared skills into their agent commands dir
145+
(e.g. ``.gemini/commands/shared -> /team/shared-commands``). The
146+
guard is purely lexical, so such a setup continues to work even though
147+
the resolved target lives outside commands_dir on disk.
148+
"""
149+
project, ext_dir = _project_and_source(tmp_path)
150+
commands_dir = project / ".gemini" / "commands"
151+
commands_dir.mkdir(parents=True)
152+
153+
external_shared = tmp_path / "external-shared"
154+
external_shared.mkdir()
155+
try:
156+
(commands_dir / "shared").symlink_to(
157+
external_shared, target_is_directory=True
158+
)
159+
except OSError as exc:
160+
if exc.errno in {errno.EPERM, errno.EACCES}:
161+
pytest.skip("symlink creation is not permitted in this environment")
162+
raise
163+
164+
registrar = CommandRegistrar()
165+
registered = registrar.register_commands(
166+
"gemini",
167+
[_cmd("shared/hello")],
168+
"myext",
169+
ext_dir,
170+
project,
171+
)
172+
173+
assert registered == ["shared/hello"]
174+
assert (external_shared / "hello.toml").exists()
175+
176+
def test_safe_command_and_alias_still_register(self, tmp_path):
177+
project, ext_dir = _project_and_source(tmp_path)
178+
(project / ".claude" / "skills").mkdir(parents=True)
179+
180+
registrar = CommandRegistrar()
181+
registered = registrar.register_commands(
182+
"claude",
183+
[_cmd("speckit.myext.hello", ["speckit.myext.hi"])],
184+
"myext",
185+
ext_dir,
186+
project,
187+
)
188+
189+
assert "speckit.myext.hello" in registered
190+
assert "speckit.myext.hi" in registered
191+
assert (
192+
project
193+
/ ".claude"
194+
/ "skills"
195+
/ "speckit-myext-hello"
196+
/ "SKILL.md"
197+
).exists()
198+
assert (
199+
project
200+
/ ".claude"
201+
/ "skills"
202+
/ "speckit-myext-hi"
203+
/ "SKILL.md"
204+
).exists()

0 commit comments

Comments
 (0)