Prompt-injection defence
Bug reports are user-supplied content that ends up in model prompts. That makes prompt injection a real threat: a report could try to override the system prompt, leak data, or steer a fix agent into a harmful change. Mushi Mushi uses several layers. None of them makes model output safe to merge unreviewed.
packages/server/src/__tests__/injection.test.ts runs the injection corpus against sanitizeForLLM() in CI (pnpm test). A corpus entry that no longer trips a pattern fails the build.
Defence layers
1. Two-stage diagnosis (air gap)
fast-filter (stage 1) is the only model that reads the report text, after scrubReport() has removed common PII. It extracts structured symptoms and a sanitized evidence summary. classify-report (stage 2) receives only that structured JSON, never the raw console lines, network URLs or stack traces, and its system prompt tells it to treat any user-supplied description as data.
2. Text sanitizer for agent prompts
Text that goes to the fix and review agents (fix-worker, agent-pr-review, review-followup, fix-verifier) and voice transcripts passes through sanitizeForLLM() in packages/server/supabase/functions/_shared/sanitize.ts. It:
- normalises Unicode (NFKC) and strips invisible and format characters (zero-width joiners, bidi controls, tag characters);
- removes HTML comments, a common hidden-instruction channel;
- removes ASCII control characters;
- decodes base64 blobs that decode to readable text and re-scans them;
- replaces known injection fragments (
ignore previous instructions, role flips,<system>,[INST],<|im_start|>, output and tool-call hijacks) with[BLOCKED_…]markers; - collapses long whitespace runs used to bury an instruction.
It does not cap field length; prompt builders apply their own budgets.
3. Fenced user content
The PR review prompt puts the report, PR description and diff in labelled data blocks, and fenceUntrusted() (_shared/fix-review.ts) stops untrusted text from opening or closing one of those blocks. The system prompt tells the model that text inside them is data, not instructions. Review findings handed back to a coding agent are screened as well: only files the PR changed, and nothing that reads as an instruction or carries a foreign URL or shell command.
4. Structured output
Classifiers and graders ask for output against a Zod schema. A response that is not valid JSON or does not match the schema throws. Where the caller has a fallback model (for example GPT-5.4 behind Claude), it retries there; otherwise the run fails. Free-form model text is never passed along in place of the structured result.
5. Screenshots
Screenshots are never sent alongside user text. After classification, classify-report makes a separate image-only call with a fixed system prompt that treats all text in the image as data, copies it into visible_text_in_image, and sets untrusted_image_instructions_detected when the image tries to give instructions. Because it runs after classification, it can annotate a report but cannot change its diagnosis. The screenshot URL must pass the SSRF host allowlist first, or the vision call is skipped.
6. Regression suite
packages/server/src/__tests__/injection.test.ts covers:
INJECTION_CORPUSfromsanitize.ts(19 payloads: instruction hijacks, role flips, system/assistant/INST/ChatML tags, output and tool hijacks, base64-wrapped, control-character, whitespace-burial, zero-width, HTML-comment and tag-character smuggling);- a local corpus checked against the expected
[BLOCKED_…]tokens; - hidden-character filtering for voice intake.
Run it locally with:
cd packages/server
npx vitest run src/__tests__/injection.test.tsWhat this does not cover
- Pattern lists catch known phrasings, not every paraphrase. The air gap and output schemas matter more than the regexes.
- A cloud coding agent reads your repository and the report; keep fix PRs as drafts and review them (or rely on the agent PR review) before merging.