Skip to content

Commit 6223d10

Browse files
authored
fix(codex): native skills fallback refresh + legacy prompt suppression (#1930)
* fix(codex): skip legacy prompts and fallback when bundled skills missing * fix(skills): allow native fallback to overwrite existing SKILL.md * fix(codex): defer legacy .codex cleanup until after skills fallback * fix(codex): preserve existing .codex while skipping legacy prompt extraction * docs(skills): clarify overwrite_existing behavior * test(codex): cover fresh-dir suppression of legacy .codex layout * docs(codex): clarify skip_legacy_codex_prompts suppresses full .codex dir * security(init): validate zip member paths before extraction
1 parent bf33980 commit 6223d10

File tree

2 files changed

+215
-27
lines changed

2 files changed

+215
-27
lines changed

src/specify_cli/__init__.py

Lines changed: 96 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -948,9 +948,26 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri
948948
}
949949
return zip_path, metadata
950950

951-
def download_and_extract_template(project_path: Path, ai_assistant: str, script_type: str, is_current_dir: bool = False, *, verbose: bool = True, tracker: StepTracker | None = None, client: httpx.Client = None, debug: bool = False, github_token: str = None) -> Path:
951+
def download_and_extract_template(
952+
project_path: Path,
953+
ai_assistant: str,
954+
script_type: str,
955+
is_current_dir: bool = False,
956+
*,
957+
skip_legacy_codex_prompts: bool = False,
958+
verbose: bool = True,
959+
tracker: StepTracker | None = None,
960+
client: httpx.Client = None,
961+
debug: bool = False,
962+
github_token: str = None,
963+
) -> Path:
952964
"""Download the latest release and extract it to create a new project.
953965
Returns project_path. Uses tracker if provided (with keys: fetch, download, extract, cleanup)
966+
967+
Note:
968+
``skip_legacy_codex_prompts`` suppresses the legacy top-level
969+
``.codex`` directory from older template archives in Codex skills mode.
970+
The name is kept for backward compatibility with existing callers.
954971
"""
955972
current_dir = Path.cwd()
956973

@@ -990,6 +1007,19 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_
9901007
project_path.mkdir(parents=True)
9911008

9921009
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
1010+
def _validate_zip_members_within(root: Path) -> None:
1011+
"""Validate all ZIP members stay within ``root`` (Zip Slip guard)."""
1012+
root_resolved = root.resolve()
1013+
for member in zip_ref.namelist():
1014+
member_path = (root / member).resolve()
1015+
try:
1016+
member_path.relative_to(root_resolved)
1017+
except ValueError:
1018+
raise RuntimeError(
1019+
f"Unsafe path in ZIP archive: {member} "
1020+
"(potential path traversal)"
1021+
)
1022+
9931023
zip_contents = zip_ref.namelist()
9941024
if tracker:
9951025
tracker.start("zip-list")
@@ -1000,6 +1030,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_
10001030
if is_current_dir:
10011031
with tempfile.TemporaryDirectory() as temp_dir:
10021032
temp_path = Path(temp_dir)
1033+
_validate_zip_members_within(temp_path)
10031034
zip_ref.extractall(temp_path)
10041035

10051036
extracted_items = list(temp_path.iterdir())
@@ -1019,6 +1050,11 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_
10191050
console.print("[cyan]Found nested directory structure[/cyan]")
10201051

10211052
for item in source_dir.iterdir():
1053+
# In Codex skills mode, do not materialize the legacy
1054+
# top-level .codex directory from older prompt-based
1055+
# template archives.
1056+
if skip_legacy_codex_prompts and ai_assistant == "codex" and item.name == ".codex":
1057+
continue
10221058
dest_path = project_path / item.name
10231059
if item.is_dir():
10241060
if dest_path.exists():
@@ -1043,6 +1079,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_
10431079
if verbose and not tracker:
10441080
console.print("[cyan]Template files merged into current directory[/cyan]")
10451081
else:
1082+
_validate_zip_members_within(project_path)
10461083
zip_ref.extractall(project_path)
10471084

10481085
extracted_items = list(project_path.iterdir())
@@ -1069,6 +1106,13 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_
10691106
elif verbose:
10701107
console.print("[cyan]Flattened nested directory structure[/cyan]")
10711108

1109+
# For fresh-directory Codex skills init, suppress legacy
1110+
# top-level .codex layout extracted from older archives.
1111+
if skip_legacy_codex_prompts and ai_assistant == "codex":
1112+
legacy_codex_dir = project_path / ".codex"
1113+
if legacy_codex_dir.is_dir():
1114+
shutil.rmtree(legacy_codex_dir, ignore_errors=True)
1115+
10721116
except Exception as e:
10731117
if tracker:
10741118
tracker.error("extract", str(e))
@@ -1499,18 +1543,27 @@ def _get_skills_dir(project_path: Path, selected_ai: str) -> Path:
14991543
return project_path / DEFAULT_SKILLS_DIR
15001544

15011545

1502-
def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker | None = None) -> bool:
1546+
def install_ai_skills(
1547+
project_path: Path,
1548+
selected_ai: str,
1549+
tracker: StepTracker | None = None,
1550+
*,
1551+
overwrite_existing: bool = False,
1552+
) -> bool:
15031553
"""Install Prompt.MD files from templates/commands/ as agent skills.
15041554
15051555
Skills are written to the agent-specific skills directory following the
15061556
`agentskills.io <https://agentskills.io/specification>`_ specification.
1507-
Installation is additive — existing files are never removed and prompt
1508-
command files in the agent's commands directory are left untouched.
1557+
Installation is additive by default — existing files are never removed and
1558+
prompt command files in the agent's commands directory are left untouched.
15091559
15101560
Args:
15111561
project_path: Target project directory.
15121562
selected_ai: AI assistant key from ``AGENT_CONFIG``.
15131563
tracker: Optional progress tracker.
1564+
overwrite_existing: When True, overwrite any existing ``SKILL.md`` file
1565+
in the target skills directory (including user-authored content).
1566+
Defaults to False.
15141567
15151568
Returns:
15161569
``True`` if at least one skill was installed or all skills were
@@ -1640,9 +1693,10 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker
16401693

16411694
skill_file = skill_dir / "SKILL.md"
16421695
if skill_file.exists():
1643-
# Do not overwrite user-customized skills on re-runs
1644-
skipped_count += 1
1645-
continue
1696+
if not overwrite_existing:
1697+
# Default behavior: do not overwrite user-customized skills on re-runs
1698+
skipped_count += 1
1699+
continue
16461700
skill_file.write_text(skill_content, encoding="utf-8")
16471701
installed_count += 1
16481702

@@ -1994,7 +2048,18 @@ def init(
19942048

19952049
if use_github:
19962050
with httpx.Client(verify=local_ssl_context) as local_client:
1997-
download_and_extract_template(project_path, selected_ai, selected_script, here, verbose=False, tracker=tracker, client=local_client, debug=debug, github_token=github_token)
2051+
download_and_extract_template(
2052+
project_path,
2053+
selected_ai,
2054+
selected_script,
2055+
here,
2056+
skip_legacy_codex_prompts=(selected_ai == "codex" and ai_skills),
2057+
verbose=False,
2058+
tracker=tracker,
2059+
client=local_client,
2060+
debug=debug,
2061+
github_token=github_token,
2062+
)
19982063
else:
19992064
scaffold_ok = scaffold_from_core_pack(project_path, selected_ai, selected_script, here, tracker=tracker)
20002065
if not scaffold_ok:
@@ -2013,7 +2078,6 @@ def init(
20132078
if not here and project_path.exists():
20142079
shutil.rmtree(project_path)
20152080
raise typer.Exit(1)
2016-
20172081
# For generic agent, rename placeholder directory to user-specified path
20182082
if selected_ai == "generic" and ai_commands_dir:
20192083
placeholder_dir = project_path / ".speckit" / "commands"
@@ -2033,16 +2097,30 @@ def init(
20332097
if ai_skills:
20342098
if selected_ai in NATIVE_SKILLS_AGENTS:
20352099
skills_dir = _get_skills_dir(project_path, selected_ai)
2036-
if not _has_bundled_skills(project_path, selected_ai):
2037-
raise RuntimeError(
2038-
f"Expected bundled agent skills in {skills_dir.relative_to(project_path)}, "
2039-
"but none were found. Re-run with an up-to-date template."
2040-
)
2041-
if tracker:
2042-
tracker.start("ai-skills")
2043-
tracker.complete("ai-skills", f"bundled skills → {skills_dir.relative_to(project_path)}")
2100+
bundled_found = _has_bundled_skills(project_path, selected_ai)
2101+
if bundled_found:
2102+
if tracker:
2103+
tracker.start("ai-skills")
2104+
tracker.complete("ai-skills", f"bundled skills → {skills_dir.relative_to(project_path)}")
2105+
else:
2106+
console.print(f"[green]✓[/green] Using bundled agent skills in {skills_dir.relative_to(project_path)}/")
20442107
else:
2045-
console.print(f"[green]✓[/green] Using bundled agent skills in {skills_dir.relative_to(project_path)}/")
2108+
# Compatibility fallback: convert command templates to skills
2109+
# when an older template archive does not include native skills.
2110+
# This keeps `specify init --here --ai codex --ai-skills` usable
2111+
# in repos that already contain unrelated skills under .agents/skills.
2112+
fallback_ok = install_ai_skills(
2113+
project_path,
2114+
selected_ai,
2115+
tracker=tracker,
2116+
overwrite_existing=True,
2117+
)
2118+
if not fallback_ok:
2119+
raise RuntimeError(
2120+
f"Expected bundled agent skills in {skills_dir.relative_to(project_path)}, "
2121+
"but none were found and fallback conversion failed. "
2122+
"Re-run with an up-to-date template."
2123+
)
20462124
else:
20472125
skills_ok = install_ai_skills(project_path, selected_ai, tracker=tracker)
20482126

tests/test_ai_skills.py

Lines changed: 119 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@
1111
"""
1212

1313
import re
14+
import zipfile
1415
import pytest
1516
import tempfile
1617
import shutil
1718
import yaml
19+
import typer
1820
from pathlib import Path
1921
from unittest.mock import patch
2022

@@ -720,8 +722,8 @@ def fake_download(project_path, *args, **kwargs):
720722
mock_skills.assert_not_called()
721723
assert (target / ".agents" / "skills" / "speckit-specify" / "SKILL.md").exists()
722724

723-
def test_codex_native_skills_missing_fails_clearly(self, tmp_path):
724-
"""Codex native skills init should fail if bundled skills are missing."""
725+
def test_codex_native_skills_missing_falls_back_then_fails_cleanly(self, tmp_path):
726+
"""Codex should attempt fallback conversion when bundled skills are missing."""
725727
from typer.testing import CliRunner
726728

727729
runner = CliRunner()
@@ -730,7 +732,7 @@ def test_codex_native_skills_missing_fails_clearly(self, tmp_path):
730732
with patch("specify_cli.download_and_extract_template", lambda *args, **kwargs: None), \
731733
patch("specify_cli.ensure_executable_scripts"), \
732734
patch("specify_cli.ensure_constitution_from_template"), \
733-
patch("specify_cli.install_ai_skills") as mock_skills, \
735+
patch("specify_cli.install_ai_skills", return_value=False) as mock_skills, \
734736
patch("specify_cli.is_git_repo", return_value=False), \
735737
patch("specify_cli.shutil.which", return_value="/usr/bin/codex"):
736738
result = runner.invoke(
@@ -739,11 +741,13 @@ def test_codex_native_skills_missing_fails_clearly(self, tmp_path):
739741
)
740742

741743
assert result.exit_code == 1
742-
mock_skills.assert_not_called()
744+
mock_skills.assert_called_once()
745+
assert mock_skills.call_args.kwargs.get("overwrite_existing") is True
743746
assert "Expected bundled agent skills" in result.output
747+
assert "fallback conversion failed" in result.output
744748

745749
def test_codex_native_skills_ignores_non_speckit_skill_dirs(self, tmp_path):
746-
"""Non-spec-kit SKILL.md files should not satisfy Codex bundled-skills validation."""
750+
"""Non-spec-kit SKILL.md files should trigger fallback conversion, not hard-fail."""
747751
from typer.testing import CliRunner
748752

749753
runner = CliRunner()
@@ -757,17 +761,108 @@ def fake_download(project_path, *args, **kwargs):
757761
with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \
758762
patch("specify_cli.ensure_executable_scripts"), \
759763
patch("specify_cli.ensure_constitution_from_template"), \
760-
patch("specify_cli.install_ai_skills") as mock_skills, \
764+
patch("specify_cli.install_ai_skills", return_value=True) as mock_skills, \
761765
patch("specify_cli.is_git_repo", return_value=False), \
762766
patch("specify_cli.shutil.which", return_value="/usr/bin/codex"):
763767
result = runner.invoke(
764768
app,
765769
["init", str(target), "--ai", "codex", "--ai-skills", "--script", "sh", "--no-git"],
766770
)
767771

768-
assert result.exit_code == 1
769-
mock_skills.assert_not_called()
770-
assert "Expected bundled agent skills" in result.output
772+
assert result.exit_code == 0
773+
mock_skills.assert_called_once()
774+
assert mock_skills.call_args.kwargs.get("overwrite_existing") is True
775+
776+
def test_codex_ai_skills_here_mode_preserves_existing_codex_dir(self, tmp_path, monkeypatch):
777+
"""Codex --here skills init should not delete a pre-existing .codex directory."""
778+
from typer.testing import CliRunner
779+
780+
runner = CliRunner()
781+
target = tmp_path / "codex-preserve-here"
782+
target.mkdir()
783+
existing_prompts = target / ".codex" / "prompts"
784+
existing_prompts.mkdir(parents=True)
785+
(existing_prompts / "custom.md").write_text("custom")
786+
monkeypatch.chdir(target)
787+
788+
with patch("specify_cli.download_and_extract_template", return_value=target), \
789+
patch("specify_cli.ensure_executable_scripts"), \
790+
patch("specify_cli.ensure_constitution_from_template"), \
791+
patch("specify_cli.install_ai_skills", return_value=True), \
792+
patch("specify_cli.is_git_repo", return_value=True), \
793+
patch("specify_cli.shutil.which", return_value="/usr/bin/codex"):
794+
result = runner.invoke(
795+
app,
796+
["init", "--here", "--ai", "codex", "--ai-skills", "--script", "sh", "--no-git"],
797+
input="y\n",
798+
)
799+
800+
assert result.exit_code == 0
801+
assert (target / ".codex").exists()
802+
assert (existing_prompts / "custom.md").exists()
803+
804+
def test_codex_ai_skills_fresh_dir_does_not_create_codex_dir(self, tmp_path):
805+
"""Fresh-directory Codex skills init should not leave legacy .codex from archive."""
806+
target = tmp_path / "fresh-codex-proj"
807+
archive = tmp_path / "codex-template.zip"
808+
809+
with zipfile.ZipFile(archive, "w") as zf:
810+
zf.writestr("template-root/.codex/prompts/speckit.specify.md", "legacy")
811+
zf.writestr("template-root/.specify/templates/constitution-template.md", "constitution")
812+
813+
fake_meta = {
814+
"filename": archive.name,
815+
"size": archive.stat().st_size,
816+
"release": "vtest",
817+
"asset_url": "https://example.invalid/template.zip",
818+
}
819+
820+
with patch("specify_cli.download_template_from_github", return_value=(archive, fake_meta)):
821+
specify_cli.download_and_extract_template(
822+
target,
823+
"codex",
824+
"sh",
825+
is_current_dir=False,
826+
skip_legacy_codex_prompts=True,
827+
verbose=False,
828+
)
829+
830+
assert target.exists()
831+
assert (target / ".specify").exists()
832+
assert not (target / ".codex").exists()
833+
834+
@pytest.mark.parametrize("is_current_dir", [False, True])
835+
def test_download_and_extract_template_blocks_zip_path_traversal(self, tmp_path, monkeypatch, is_current_dir):
836+
"""Extraction should reject ZIP members escaping the target directory."""
837+
target = (tmp_path / "here-proj") if is_current_dir else (tmp_path / "new-proj")
838+
if is_current_dir:
839+
target.mkdir()
840+
monkeypatch.chdir(target)
841+
842+
archive = tmp_path / "malicious-template.zip"
843+
with zipfile.ZipFile(archive, "w") as zf:
844+
zf.writestr("../evil.txt", "pwned")
845+
zf.writestr("template-root/.specify/templates/constitution-template.md", "constitution")
846+
847+
fake_meta = {
848+
"filename": archive.name,
849+
"size": archive.stat().st_size,
850+
"release": "vtest",
851+
"asset_url": "https://example.invalid/template.zip",
852+
}
853+
854+
with patch("specify_cli.download_template_from_github", return_value=(archive, fake_meta)):
855+
with pytest.raises(typer.Exit):
856+
specify_cli.download_and_extract_template(
857+
target,
858+
"codex",
859+
"sh",
860+
is_current_dir=is_current_dir,
861+
skip_legacy_codex_prompts=True,
862+
verbose=False,
863+
)
864+
865+
assert not (tmp_path / "evil.txt").exists()
771866

772867
def test_commands_preserved_when_skills_fail(self, tmp_path):
773868
"""If skills fail, commands should NOT be removed (safety net)."""
@@ -859,6 +954,21 @@ def test_fresh_install_writes_all_skills(self, project_dir, templates_dir):
859954
# All 4 templates should produce skills (specify, plan, tasks, empty_fm)
860955
assert len(skill_dirs) == 4
861956

957+
def test_existing_skill_overwritten_when_enabled(self, project_dir, templates_dir):
958+
"""When overwrite_existing=True, pre-existing SKILL.md should be replaced."""
959+
skill_dir = project_dir / ".claude" / "skills" / "speckit-specify"
960+
skill_dir.mkdir(parents=True)
961+
custom_content = "# My Custom Specify Skill\nUser-modified content\n"
962+
skill_file = skill_dir / "SKILL.md"
963+
skill_file.write_text(custom_content)
964+
965+
result = install_ai_skills(project_dir, "claude", overwrite_existing=True)
966+
967+
assert result is True
968+
updated_content = skill_file.read_text()
969+
assert updated_content != custom_content
970+
assert "name: speckit-specify" in updated_content
971+
862972

863973
# ===== SKILL_DESCRIPTIONS Coverage Tests =====
864974

0 commit comments

Comments
 (0)