feat(security): prompt injection guardrails for all prompt templates (#390)

* feat(security): add prompt injection guardrails for all prompt templates - Add untrusted-content boundary fencing to all external data injection points: analyze-press-context.md, analyze-weekly.md, analyze-topic.md, reskill.md, reskill-scorecard.md - Add closing security constraints to templates that lacked them - Expand injection phrase detection in sanitize_repo_content.py (16 phrases) - Add sanitize_text() general-purpose function for article titles, topic descriptions, and other free-form untrusted text - Sanitize topic_name and topic_description in render_topic_prompt.py - Sanitize article titles in render_press_context.py - Add scripts/lint_prompts.py CI linter for prompt template guardrails - Add docs/prompt-injection-guardrails.md with full threat model and defense documentation - Add tests for sanitize_text and prompt linter Closes #352 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(security): address PR review feedback on prompt injection guardrails - Remove unused module-level 're' import in render_topic_prompt.py - Use try/except for sanitize_repo_content import to support both package and direct script invocation contexts - Fix sanitize_text() to coerce non-string inputs (return '' for None, str(x) for other types) so return type is always str - Add {{QUALITY_TREND}} to UNTRUSTED_VARIABLES and fence it in prompts/reskill.md with <untrusted-content> tags - Add unknown-variable detection to lint_prompts.py so new template variables must be explicitly classified Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(security): sanitize non-string values in sanitize_text() Previously, non-string inputs were coerced via str() but returned immediately without going through the full sanitization pipeline (boundary escaping, injection phrase detection, truncation). Now they flow through the same path as string inputs. Addresses PR review feedback on #381. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(security): address remaining PR review feedback on #381 - sanitize_text() now respects caller max_length as upper bound even when suspicious phrases are detected (uses min of SUSPICIOUS_DESCRIPTION_LENGTH and max_length). - Generalize single-brace format variable fencing lint to all templates, not just press-context. Adds UNTRUSTED_FORMAT_VARIABLES set so new untrusted format vars are enforced regardless of filename. - Add test for max_length cap behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix prompt guardrail review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(security): use neutral replacement for boundary markers and sanitize all article fields - Replace boundary escape from resembling a tag (<\/...>) to neutral placeholder text ([boundary-close-removed], [boundary-open-removed]) - Sanitize url, categories, source, published_at in article rendering to prevent injection via non-title fields Addresses Copilot review feedback on PR #381. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix review feedback on prompt boundary sanitization Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(test): resolve prompts dir relative to test file, not CWD Addresses Copilot review comment: test_lint_main_passes_real_prompts was CWD-dependent, making it flaky in CI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(security): validate topic_id before prompt injection, guard max_length - Validate topic_id with regex in render_template() before inserting into prompt, preventing boundary-tag injection via squadscope.topic.yml - Guard against non-positive max_length in sanitize_text() to prevent unexpected _truncate() behavior - Update guardrails doc to accurately state where topic_id validation occurs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix review feedback: topic_id type coercion and max_length<=0 test - Add type checking/coercion for topic_id before re.fullmatch to handle None or non-string values from YAML without raising TypeError - Add unit test for sanitize_text with max_length <= 0 (and negative) to cover the fallback branch to MAX_DESCRIPTION_LENGTH 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 02:51 UTC ab650da794ff829bc027083a64a522a1b2babade
5 files changed +24 -2
docs/prompt-injection-guardrails.md
+1 -1
@@ -100,7 +100,7 @@ When creating or modifying prompt templates:
100 | `{{TOPIC_DESCRIPTION}}` | SEMI-TRUSTED | No (sanitized in code) |
101 | `{{CURRENT_DATETIME}}` | TRUSTED | No |
102 | `{{OUTPUT_PATH}}` | TRUSTED | No |
103 -| `{{TOPIC_ID}}` | TRUSTED | No (regex-validated) |
103 +| `{{TOPIC_ID}}` | TRUSTED | No (regex-validated in `render_template()` before prompt insertion) |
104
105 ## Scope
106
scripts/render_topic_prompt.py
+10
@@ -109,9 +109,19 @@ def render_template(template: str, topic_config: dict | None) -> str:
109 has_topic = topic_config is not None and bool(topic_config.get("id") or topic_config.get("name"))
110
111 if has_topic:
112 + import re as _re
113 +
114 topic_id = topic_config.get("id", "")
115 + # Coerce topic_id to string to avoid TypeError in re.fullmatch
116 + if not isinstance(topic_id, str):
117 + topic_id = str(topic_id) if topic_id is not None else ""
118 topic_name = topic_config.get("name", "")
119 topic_description = topic_config.get("description", "")
120 +
121 + # Validate topic_id before prompt injection (same regex as load_wisdom)
122 + if not _re.fullmatch(r"[a-z0-9][a-z0-9\-_]{0,63}", topic_id):
123 + topic_id = ""
124 +
125 wisdom_content = load_wisdom(topic_id)
126
127 # Sanitize user-controlled topic fields
scripts/sanitize_repo_content.py
+2
@@ -77,6 +77,8 @@ def sanitize_text(
77 function works on any free-form text (article titles, topic descriptions,
78 scorecard summaries, etc.).
79 """
80 + if max_length <= 0:
81 + max_length = MAX_DESCRIPTION_LENGTH
82 if text is None:
83 return ""
84 if not isinstance(text, str):
tests/test_lint_prompts.py
+2 -1
@@ -47,7 +47,8 @@ def test_lint_fails_on_unfenced_untrusted_variable(tmp_path: Path) -> None:
47
48 def test_lint_main_passes_real_prompts() -> None:
49 """Ensure the actual prompts/ directory passes lint."""
50 - result = main(["--prompts-dir", "prompts"])
50 + prompts_dir = Path(__file__).resolve().parent.parent / "prompts"
51 + result = main(["--prompts-dir", str(prompts_dir)])
52 assert result == 0, "Real prompt templates failed lint — fix them before merging"
53
54
tests/test_sanitize_repo_content.py
+9
@@ -139,3 +139,12 @@ def test_sanitize_text_max_length_caps_suspicious_limit() -> None:
139 # Caller wants a tight budget of 50 chars
140 sanitized = sanitize_text(text, max_length=50, label="test")
141 assert len(sanitized) <= 50
142 +
143 +
144 +def test_sanitize_text_non_positive_max_length_uses_default() -> None:
145 + """max_length <= 0 should fall back to MAX_DESCRIPTION_LENGTH."""
146 + text = "a" * 1000
147 + for bad_length in (0, -1, -100):
148 + sanitized = sanitize_text(text, max_length=bad_length, label="test")
149 + assert len(sanitized) == MAX_DESCRIPTION_LENGTH
150 + assert sanitized.endswith("…")