Skip to content

Commit dfde970

Browse files
PascalThuetclaude
andcommitted
fix(dev): harden scaffold writes and accept case-insensitive --type
- Guard scaffold_integration() against symlinked target directories: walk each path component under the repo root and refuse symlinked dirs, then confirm the write destination resolves inside the repo (mirrors the manifest directory guard). Prevents scaffolding outside the repo when a contributor's integrations/tests path is symlinked. - Make the `--type` click.Choice case-insensitive so `--type YAML` is accepted, matching scaffold_integration()'s strip()/lower() normalization instead of rejecting at the CLI layer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 127b4f1 commit dfde970

3 files changed

Lines changed: 72 additions & 1 deletion

File tree

src/specify_cli/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -595,7 +595,7 @@ def dev_integration_scaffold(
595595
integration_type: str = typer.Option(
596596
"markdown",
597597
"--type",
598-
click_type=click.Choice(INTEGRATION_SCAFFOLD_TYPES),
598+
click_type=click.Choice(INTEGRATION_SCAFFOLD_TYPES, case_sensitive=False),
599599
help=f"Scaffold type: {', '.join(INTEGRATION_SCAFFOLD_TYPES)}",
600600
),
601601
):

src/specify_cli/integration_scaffold.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,37 @@ def _is_spec_kit_repo_root(project_root: Path) -> bool:
175175
)
176176

177177

178+
def _assert_safe_scaffold_target(project_root: Path, target: Path) -> None:
179+
"""Refuse to scaffold through a symlinked path that could escape the repo.
180+
181+
Walks each component of *target* under *project_root* and rejects any
182+
existing symlinked directory (or symlinked target), then confirms the
183+
write destination still resolves inside the repository root. Mirrors the
184+
symlink-aware guarding used for integration manifests.
185+
"""
186+
try:
187+
rel = target.relative_to(project_root)
188+
except ValueError:
189+
raise ValueError(
190+
f"Refusing to scaffold outside the repository root: {target}"
191+
) from None
192+
193+
current = project_root
194+
for part in rel.parts:
195+
current = current / part
196+
if current.is_symlink():
197+
label = current.relative_to(project_root).as_posix()
198+
raise ValueError(f"Refusing to scaffold through symlinked path: {label}")
199+
200+
root_resolved = project_root.resolve()
201+
try:
202+
target.parent.resolve().relative_to(root_resolved)
203+
except (OSError, ValueError):
204+
raise ValueError(
205+
f"Refusing to scaffold outside the repository root: {target}"
206+
) from None
207+
208+
178209
def scaffold_integration(
179210
project_root: Path,
180211
key: str,
@@ -200,6 +231,9 @@ def scaffold_integration(
200231
integration_file = integration_dir / "__init__.py"
201232
test_file = tests_root / f"test_integration_{package_name}.py"
202233

234+
for target in (integration_file, test_file):
235+
_assert_safe_scaffold_target(project_root, target)
236+
203237
existing = [path for path in (integration_file, test_file) if path.exists()]
204238
if existing:
205239
labels = ", ".join(path.relative_to(project_root).as_posix() for path in existing)

tests/integrations/test_integration_scaffold.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,3 +135,40 @@ def test_scaffold_requires_integration_registry_file(tmp_path):
135135

136136
with pytest.raises(ValueError, match="Spec Kit repository root"):
137137
scaffold_integration(root, "my-agent", "markdown")
138+
139+
140+
def test_scaffold_refuses_symlinked_target_directory(tmp_path):
141+
root = _repo_root(tmp_path)
142+
# `outside` carries its own __init__.py so the repo-root heuristic still
143+
# passes through the symlink, isolating the symlink guard under test.
144+
outside = tmp_path / "outside"
145+
outside.mkdir()
146+
(outside / "__init__.py").write_text("", encoding="utf-8")
147+
integrations = root / "src" / "specify_cli" / "integrations"
148+
(integrations / "__init__.py").unlink()
149+
integrations.rmdir()
150+
try:
151+
integrations.symlink_to(outside, target_is_directory=True)
152+
except OSError as exc:
153+
pytest.skip(f"symlinks unavailable: {exc}")
154+
155+
with pytest.raises(ValueError, match="symlinked path"):
156+
scaffold_integration(root, "my-agent", "markdown")
157+
158+
assert not (outside / "my_agent").exists()
159+
160+
161+
def test_dev_integration_scaffold_accepts_uppercase_type(tmp_path, monkeypatch):
162+
root = _repo_root(tmp_path)
163+
monkeypatch.chdir(root)
164+
165+
result = runner.invoke(app, [
166+
"dev", "integration", "scaffold", "my-agent",
167+
"--type", "YAML",
168+
], catch_exceptions=False)
169+
170+
assert result.exit_code == 0, strip_ansi(result.output)
171+
content = (
172+
root / "src" / "specify_cli" / "integrations" / "my_agent" / "__init__.py"
173+
).read_text(encoding="utf-8")
174+
assert "class MyAgentIntegration(YamlIntegration):" in content

0 commit comments

Comments
 (0)