diff --git a/src/core-skills/bmad-brainstorming/scripts/brain.py b/src/core-skills/bmad-brainstorming/scripts/brain.py index 1028cf6edb..b559535f1d 100644 --- a/src/core-skills/bmad-brainstorming/scripts/brain.py +++ b/src/core-skills/bmad-brainstorming/scripts/brain.py @@ -130,7 +130,11 @@ def resolve_detail(row: dict, csv_dir: Path) -> str | None: (or the file is missing — a missing file is reported to stderr, not fatal).""" if not row.get("detail"): return None - path = (csv_dir / row["detail"]).resolve() + base = csv_dir.resolve() + path = (base / row["detail"]).resolve() + if not path.is_relative_to(base): + print(f"# detail path escapes the catalog directory for {row['technique_name']}: {row['detail']}", file=sys.stderr) + return None if not path.is_file(): print(f"# detail file not found for {row['technique_name']}: {row['detail']}", file=sys.stderr) return None diff --git a/src/core-skills/bmad-brainstorming/scripts/tests/test_brain.py b/src/core-skills/bmad-brainstorming/scripts/tests/test_brain.py index 329a6a5f3c..549def26b6 100644 --- a/src/core-skills/bmad-brainstorming/scripts/tests/test_brain.py +++ b/src/core-skills/bmad-brainstorming/scripts/tests/test_brain.py @@ -74,6 +74,33 @@ def test_resolve_detail_missing_file_warns_not_fatal(lib, capsys): assert "not found" in capsys.readouterr().err +def test_resolve_detail_refuses_path_traversal(lib, tmp_path, capsys): + """Paths that escape the catalog directory must be refused, not read.""" + rows = brain.load(lib) + secret = tmp_path.parent / ".env" + secret.write_text("SECRET=hunter2", encoding="utf-8") + # relative traversal + rows[1]["detail"] = "../.env" + assert brain.resolve_detail(rows[1], lib.parent) is None + assert "escapes" in capsys.readouterr().err + # absolute path (pathlib '/' lets the right operand win) + rows[1]["detail"] = str(secret.resolve()) + assert brain.resolve_detail(rows[1], lib.parent) is None + assert "escapes" in capsys.readouterr().err + # symlink inside catalog pointing outside + link = lib.parent / "techniques" / "escape.md" + link.symlink_to(secret) + rows[1]["detail"] = "techniques/escape.md" + assert brain.resolve_detail(rows[1], lib.parent) is None + assert "escapes" in capsys.readouterr().err + + +def test_resolve_detail_still_reads_valid_detail(lib): + """A legitimate detail file inside the catalog directory still resolves.""" + row = next(r for r in brain.load(lib) if r["detail"]) + assert "multi-step instructions" in brain.resolve_detail(row, lib.parent) + + def test_show_inlines_detail(lib, capsys): assert brain.main(["--file", str(lib), "show", "Quantum Superposition"]) == 0 out = capsys.readouterr().out