feat: complete prompt injection guardrails — defense-in-depth and acceptance verification (#471)

* feat: complete prompt injection guardrails — defense-in-depth and acceptance verification - Add defense-in-depth boundary escaping in assemble_historical_context.py so historical context is safe even when used outside analyze_fallback.py - Add acceptance criteria verification table to guardrails documentation - Update threat model entry for historical context with new defense layer Closes #352 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address review comments 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 14, 2026 at 10:23 UTC 45dc83834ca095554e91ba8b63229cbf14d2c1a8
2 files changed +28 -1
docs/prompt-injection-guardrails.md
+14 -1
@@ -11,7 +11,7 @@ SquadScope ingests external text from multiple untrusted sources:
11 | GitHub repo descriptions | `data/raw/*.json` → prompt templates | HIGH — attacker controls repo description | `preprocess_for_analysis.py` → `sanitize_description()` |
12 | TechCrunch/RSS article titles | crawl data → `render_press_context.py` | MEDIUM — unlikely but possible | `render_press_context.format_articles_list()` → `sanitize_text()` |
13 | Previous analysis output | `data/analyzed/*.md` → prompt templates | HIGH — poisoned output persists | `analyze_fallback.py` → `_escape_untrusted_boundaries()` |
14 -| Historical context (rolling/monthly/yearly) | `content/` → `assemble_historical_context.py` | HIGH — poisoned output persists | `analyze_fallback.py` line 742 → `_escape_untrusted_boundaries()` |
14 +| Historical context (rolling/monthly/yearly) | `content/` → `assemble_historical_context.py` | HIGH — poisoned output persists | `assemble_historical_context._escape_boundaries()` (defense-in-depth) + final historical-context escaping in `analyze_fallback.py` prompt assembly → `_escape_untrusted_boundaries()` |
15 | README snippets | GitHub API → correlation narratives | MEDIUM — attacker controls README | `render_press_context._extract_readme_description()` (structural filtering) |
16 | Correlation match data | `correlate.py` → `render_press_context.py` | MEDIUM — sanitized at source | `correlate.py` → `sanitize_text()` at output time |
17 | Wisdom files | `.squad/identity/wisdom.md` → prompt templates | MEDIUM — prior LLM output | `reskill.render_wisdom()` → `_escape_untrusted_boundaries()` |
@@ -264,6 +264,19 @@ The following summarizes the complete defense chain from data ingestion to publi
264 └─────────────────────────────────────────────┘
265 ```
266
267 +## Acceptance Criteria Verification (Issue #352)
268 +
269 +| Criterion | Status | Evidence |
270 +|-----------|--------|----------|
271 +| Inventory every prompt and imported text source | ✅ | Threat model table above lists all 12 sources |
272 +| Untrusted-content fences on all external text | ✅ | All 6 prompt templates fenced; lint enforced |
273 +| Length caps and normalization | ✅ | `sanitize_text()` / `sanitize_description()` with 200/500 char limits |
274 +| Prompt lint/check that fails on unguarded variables | ✅ | `lint_prompts.py` + `test_prompt_lint_ci.py` in CI |
275 +| Canary-token output leak detection | ✅ | `canary_token.py` integrated in `analyze_fallback.py` and `reskill.py` |
276 +| Red-team corpus test with known injection strings | ✅ | `test_redteam_corpus.py` (7 categories) + `test_prompt_injection_redteam.py` (18 strings) |
277 +| Evaluate Garak, LLM Guard, Azure Prompt Shields | ✅ | Tool Evaluation section above with verdicts |
278 +| Validate generated output schema/frontmatter | ✅ | `generate_content._validate_frontmatter_safety()` + `validate_output_safety()` |
279 +
280 ## Phase 3 Follow-up Work
281
282 - **Azure Prompt Shields integration** — add as optional pre-flight injection scanner
scripts/assemble_historical_context.py
+14
@@ -279,6 +279,17 @@ def _build_plans(
279 return plans
280
281
282 +def _escape_boundaries(text: str) -> str:
283 + """Defense-in-depth: escape untrusted-content boundary markers in assembled text."""
284 + try:
285 + from scripts.sanitize_repo_content import _escape_untrusted_boundaries
286 + except ModuleNotFoundError: # pragma: no cover - script execution path
287 + sys.path.insert(0, str(ROOT))
288 + from scripts.sanitize_repo_content import _escape_untrusted_boundaries
289 +
290 + return _escape_untrusted_boundaries(text)
291 +
292 +
293 def _render_sections(plans: Iterable[_SectionPlan]) -> tuple[str, tuple[HistoricalContextSection, ...]]:
294 rendered_sections: list[str] = []
295 metadata: list[HistoricalContextSection] = []
@@ -288,6 +299,9 @@ def _render_sections(plans: Iterable[_SectionPlan]) -> tuple[str, tuple[Historic
299 content = compress_to_budget(plan.raw_content, plan.current_words)
300 if not content:
301 continue
302 + # Defense-in-depth: escape boundary markers even though the caller
303 + # (analyze_fallback.py) also escapes the final assembled string.
304 + content = _escape_boundaries(content)
305 rendered_sections.append(f"### {plan.label}\n\n{content}")
306 metadata.append(
307 HistoricalContextSection(