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>