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(