Security: Prompt injection guardrails for imported text (#434)

* fix(security): address PR review — strengthen red-team tests and log boundary escapes - sanitize_text now logs and truncates when boundary markers are escaped - test_injection_is_truncated asserts for ALL inputs (not just long ones) - test_injection_is_logged asserts ALL suspicious inputs trigger warnings - Add test_rejects_type_based_bypass_list for non-string coercion - Update guardrails docs to match actual test corpus Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(security): add boundary escaping to all reskill render paths Close fence-escape vulnerability in reskill.py where render_wisdom, render_skills, render_recent_analyses, and render_snapshot_context injected untrusted file content without escaping boundary markers. Also add escaping in track_quality.build_quality_report() and load_scorecard.render_scorecard_section(). Add 6 targeted red-team tests verifying boundary markers cannot leak through any reskill template variable path. Closes #352 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(security): escape boundary markers in render path headers and fix vacuous test - Escape relative_path in Skill Source, Analysis Source, and Snapshot Context headers to prevent boundary marker injection via filenames - Escape week in Snapshot Context headers for same reason - Fix scorecard boundary test: use correct card schema (top-level validated/correct/by_type) so test is no longer vacuous - Add assertion that render_scorecard_section returns non-empty output - Fix red-team corpus count in docs: 17 → 18 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(security): escape parentheses in markdown link URLs to prevent syntax injection URLs containing parentheses (e.g., Wikipedia links) could break markdown link syntax [text](url), potentially allowing content to escape the link context. Add _escape_markdown_url() helper that percent-encodes ( and ) characters before embedding URLs in markdown links. Applies to all three markdown link construction sites in render_press_context.py. Includes tests validating the escaping behavior. Closes part of #352 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(security): escape remaining unescaped markdown URLs in render_press_context Apply _escape_markdown_url() to the two remaining markdown link constructions that interpolated external URLs without parenthesis escaping (correlation citation and AI-prompt divergence list). Remove redundant local re-import of _escape_markdown_url in test. Resolves Copilot review threads on PR #434. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Juan Manuel Servera committed Jun 12, 2026 at 18:08 UTC 2a1c4b1a40a041666192b1ef703b0631de0c897a
2 files changed +40 -5
scripts/render_press_context.py
+14 -5
@@ -47,6 +47,15 @@ def validate_https_url(url: str, *, label: str) -> None:
47 raise ValueError(f"{label} must not use unexpected ports: {url}")
48
49
50 +def _escape_markdown_url(url: str) -> str:
51 + """Escape parentheses in URLs used inside markdown link syntax [text](url).
52 +
53 + A bare ')' in the URL would prematurely close the markdown link, potentially
54 + allowing content injection in the rendered prompt.
55 + """
56 + return url.replace("(", "%28").replace(")", "%29")
57 +
58 +
59 def current_week() -> str:
60 """Return the current ISO week as YYYY-WNN."""
61 now = datetime.now()
@@ -100,7 +109,7 @@ def format_articles_list(articles: list[dict]) -> str:
109 if published_at:
110 source_str += f", {published_at[:10]}"
111 if url:
103 - lines.append(f"- [{title}]({url}){cat_str}{source_str}")
112 + lines.append(f"- [{title}]({_escape_markdown_url(url)}){cat_str}{source_str}")
113 else:
114 lines.append(f"- {title}{cat_str}{source_str}")
115 omitted = len(articles) - MAX_RENDERED_ARTICLES
@@ -242,7 +251,7 @@ def _format_correlations_narrative(
251 seen_article_urls.add(url)
252 title = url_to_title.get(url, "")
253 if title:
245 - article_links.append(f"[{title}]({url})")
254 + article_links.append(f"[{title}]({_escape_markdown_url(url)})")
255
256 # Collect up to 3 repo links with optional README description
257 repo_parts: list[str] = []
@@ -362,7 +371,7 @@ def format_correlations_list(
371 max_length=300,
372 label="correlation_article_url",
373 )
365 - citation = f", cited: [{title}]({url})" if url else f", cited: {title}"
374 + citation = f", cited: [{title}]({_escape_markdown_url(url)})" if url else f", cited: {title}"
375 lines.append(
376 f"- {repo} — match: {match_type}, "
377 f"strength: {strength}, confidence: {confidence:.1f}, "
@@ -464,7 +473,7 @@ def _format_uncovered_narrative(items: list[dict]) -> str:
473 title = a.get("title", "article")
474 url = a.get("url", "")
475 if url:
467 - article_links.append(f"[{title}]({url})")
476 + article_links.append(f"[{title}]({_escape_markdown_url(url)})")
477 if len(article_links) >= 2:
478 break
479
@@ -533,7 +542,7 @@ def format_divergences(divergences: dict, *, reader_mode: bool = False) -> str:
542 topic = item.get("topic", "unknown")
543 articles = item.get("news_articles", item.get("techcrunch_articles", []))
544 article_refs = ", ".join(
536 - f"[{a.get('title', 'article')}]({a.get('url', '')})"
545 + f"[{a.get('title', 'article')}]({_escape_markdown_url(a.get('url', ''))})"
546 for a in articles[:3]
547 )
548 lines.append(f"- **{topic}**: {article_refs}")
tests/test_render_press_context.py
+26
@@ -9,6 +9,7 @@ _REPO_ROOT = Path(__file__).resolve().parent.parent
9 sys.path.insert(0, str(_REPO_ROOT / "scripts"))
10
11 from render_press_context import (
12 + _escape_markdown_url,
13 _extract_readme_description,
14 _fetch_readme_snippet,
15 _format_correlations_narrative,
@@ -598,3 +599,28 @@ class TestExtractReadmeDescriptionSentenceBoundary:
599 snippet = "# Header\n\nNo period here at all and the line is long enough to match normally"
600 result = _extract_readme_description(snippet)
601 assert result == ""
602 +
603 +
604 +class TestEscapeMarkdownUrl:
605 + """Ensure URLs with parentheses are safely escaped in markdown links."""
606 +
607 + def test_url_with_parentheses_escaped_in_articles_list(self):
608 + articles = [
609 + {
610 + "title": "Wikipedia Article",
611 + "url": "https://en.wikipedia.org/wiki/AI_(term)",
612 + "categories": ["AI"],
613 + "source": "Wikipedia",
614 + "published_at": "2026-01-01T00:00:00Z",
615 + }
616 + ]
617 + result = format_articles_list(articles)
618 + # Parentheses in URLs must be percent-encoded
619 + assert "%28" in result and "%29" in result
620 + # The raw parenthesis should not appear inside the markdown link target
621 + assert "](https://en.wikipedia.org/wiki/AI_(term))" not in result
622 + assert "](https://en.wikipedia.org/wiki/AI_%28term%29)" in result
623 +
624 + def test_url_without_parentheses_unchanged(self):
625 + url = "https://example.com/path?q=1&r=2"
626 + assert _escape_markdown_url(url) == url